Compare commits
2
Commits
dea77d463f
...
ba0903714c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba0903714c | ||
|
|
0540e5ff5b |
@@ -1,18 +1,19 @@
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { isToolCallEventType } from "@earendil-works/pi-coding-agent";
|
||||
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
||||
import { isToolCallEventType } from '@earendil-works/pi-coding-agent';
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
if (!isToolCallEventType("bash", event)) return;
|
||||
pi.on('tool_call', async (event, ctx) => {
|
||||
if (!isToolCallEventType('bash', event)) return;
|
||||
|
||||
const cmd = event.input.command || "";
|
||||
const isYxCommand = cmd.includes("yx ");
|
||||
const cmd = event.input.command || '';
|
||||
const isYxCommand = cmd.includes('yx ');
|
||||
|
||||
// Allow yaks in interactive mode, block in print mode (sub-agents)
|
||||
if (isYxCommand && ctx.mode === "print") {
|
||||
if (isYxCommand && ctx.mode === 'print') {
|
||||
return {
|
||||
block: true,
|
||||
reason: "yx commands are disabled in print mode. Sub-agents must focus on domain work only.",
|
||||
reason:
|
||||
'yx commands are disabled in print mode. Sub-agents must focus on domain work only.',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ Coordinate sub-agent work using the `yx` CLI. The main agent creates tasks and d
|
||||
## Running Sub-Agents
|
||||
|
||||
Sub-agents execute in print mode and are **hard-blocked from using `yx` commands** (see `.pi/extensions/yak-mode-gate.ts`). They receive:
|
||||
|
||||
- The yak's `.context.md` (task description)
|
||||
- `AGENTS.md` (project conventions)
|
||||
- Domain files to work on
|
||||
@@ -43,6 +44,7 @@ cat .yaks/<task-id>/.state
|
||||
```
|
||||
|
||||
States:
|
||||
|
||||
- `pending` — not yet started
|
||||
- `in-progress` — being worked on
|
||||
- `done` — completed
|
||||
@@ -54,6 +56,7 @@ yx done <task-id>
|
||||
```
|
||||
|
||||
Always verify before marking done:
|
||||
|
||||
```bash
|
||||
# 1. Check state
|
||||
cat .yaks/<task-id>/.state
|
||||
@@ -68,6 +71,7 @@ npm run checks
|
||||
## Sub-Agent Communication
|
||||
|
||||
Sub-agents can read yak state files directly (no yx CLI needed):
|
||||
|
||||
```bash
|
||||
cat .yaks/<task-id>/.name # task name
|
||||
cat .yaks/<task-id>/.state # current state
|
||||
|
||||
@@ -52,7 +52,7 @@ The TypeScript implementation embraces functional patterns:
|
||||
All five user stories are implemented and verified:
|
||||
|
||||
| Story | Topic | Status |
|
||||
|-------|-------|--------|
|
||||
| ----- | --------------------------- | ------- |
|
||||
| 1 | Character Creation & Damage | ✅ Done |
|
||||
| 2 | Levels | ✅ Done |
|
||||
| 3 | Factions | ✅ Done |
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import noPrimitiveValue from './eslint/no-primitive-value-properties.js';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules', 'coverage', 'allium-main'] },
|
||||
@@ -17,9 +18,17 @@ export default tseslint.config(
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
rpg: {
|
||||
rules: {
|
||||
'no-primitive-value-properties': noPrimitiveValue,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'warn',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'warn',
|
||||
'rpg/no-primitive-value-properties': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join, parse } from 'node:path';
|
||||
|
||||
const VALUE_OBJECTS_DIR = join(import.meta.dirname, '..', 'src', 'value-objects');
|
||||
|
||||
function discoverValueObjects() {
|
||||
const files = readdirSync(VALUE_OBJECTS_DIR).filter((f) => f.endsWith('.ts') && f !== 'index.ts');
|
||||
return files.map((f) => parse(f).name);
|
||||
}
|
||||
|
||||
const VALUE_OBJECTS = discoverValueObjects();
|
||||
|
||||
function buildPropertyMap() {
|
||||
const map = {};
|
||||
for (const name of VALUE_OBJECTS) {
|
||||
const propertyName = name.charAt(0).toLowerCase() + name.slice(1);
|
||||
map[propertyName] = name;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
const PROPERTY_MAP = buildPropertyMap();
|
||||
|
||||
/** Map ESLint AST type names to the primitive keyword string. */
|
||||
const PRIMITIVE_TYPE_MAP = {
|
||||
TSNumberKeyword: 'number',
|
||||
TSStringKeyword: 'string',
|
||||
TSBooleanKeyword: 'boolean',
|
||||
};
|
||||
|
||||
function isPrimitiveType(typeNode) {
|
||||
if (!typeNode) return false;
|
||||
const keyword = PRIMITIVE_TYPE_MAP[typeNode.type];
|
||||
return keyword !== undefined;
|
||||
}
|
||||
|
||||
function getPrimitiveKeyword(typeNode) {
|
||||
if (!typeNode) return null;
|
||||
return PRIMITIVE_TYPE_MAP[typeNode.type] || null;
|
||||
}
|
||||
|
||||
function getParamName(param) {
|
||||
if (param.type === 'TSParameterProperty') {
|
||||
return getParamName(param.parameter);
|
||||
}
|
||||
if (param.type === 'Identifier') {
|
||||
return param.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Warn when primitive types are used in class properties or constructor parameters that should be value objects',
|
||||
recommended: false,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeConstructorParams: { type: 'boolean', default: true },
|
||||
includeProperties: { type: 'boolean', default: true },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
primitiveProperty:
|
||||
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for property "{{propertyName}}". Available: {{available}}',
|
||||
primitiveParam:
|
||||
'Use value object "{{valueObject}}" instead of primitive "{{primitive}}" for parameter "{{paramName}}". Available: {{available}}',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const options = context.options[0] || {};
|
||||
const includeConstructorParams = options.includeConstructorParams !== false;
|
||||
const includeProperties = options.includeProperties !== false;
|
||||
|
||||
function reportPrimitive(node, propName, primitiveType, messageId) {
|
||||
const valueObject = PROPERTY_MAP[propName];
|
||||
if (valueObject) {
|
||||
context.report({
|
||||
node,
|
||||
messageId,
|
||||
data: {
|
||||
valueObject,
|
||||
primitive: primitiveType,
|
||||
propertyName: propName,
|
||||
paramName: propName,
|
||||
available: VALUE_OBJECTS.join(', '),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ClassProperty(node) {
|
||||
if (!includeProperties) return;
|
||||
if (!node.typeAnnotation) return;
|
||||
|
||||
const typeNode = node.typeAnnotation.typeAnnotation;
|
||||
if (!isPrimitiveType(typeNode)) return;
|
||||
|
||||
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
|
||||
},
|
||||
|
||||
PropertyDefinition(node) {
|
||||
if (!includeProperties) return;
|
||||
if (!node.typeAnnotation) return;
|
||||
|
||||
const typeNode = node.typeAnnotation.typeAnnotation;
|
||||
if (!isPrimitiveType(typeNode)) return;
|
||||
|
||||
reportPrimitive(node, node.key.name, getPrimitiveKeyword(typeNode), 'primitiveProperty');
|
||||
},
|
||||
|
||||
MethodDefinition(node) {
|
||||
if (!includeConstructorParams) return;
|
||||
if (node.key.name !== 'constructor') return;
|
||||
if (!node.value.params) return;
|
||||
|
||||
for (const param of node.value.params) {
|
||||
let paramName = null;
|
||||
let typeNode = null;
|
||||
|
||||
if (param.type === 'TSParameterProperty') {
|
||||
paramName = getParamName(param.parameter);
|
||||
typeNode = param.parameter.typeAnnotation?.typeAnnotation;
|
||||
} else if (param.type === 'Identifier') {
|
||||
paramName = param.name;
|
||||
typeNode = param.typeAnnotation?.typeAnnotation;
|
||||
}
|
||||
|
||||
if (!paramName || !typeNode || !isPrimitiveType(typeNode)) continue;
|
||||
|
||||
reportPrimitive(param, paramName, getPrimitiveKeyword(typeNode), 'primitiveParam');
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
+17
-14
@@ -38,7 +38,7 @@ describe('Magical Objects', () => {
|
||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||
const result = attacker.useWeapon(weapon, target);
|
||||
return result.weapon.health === weaponHP - 1;
|
||||
return result.weapon.health.value === weaponHP - 1;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -86,11 +86,11 @@ describe('Magical Objects', () => {
|
||||
// Kill the attacker using a separate killer
|
||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
||||
const deadAttacker = killer.dealDamage(attacker, 10000);
|
||||
const weaponHPBefore = weapon.health;
|
||||
const weaponHPBefore = weapon.health.value;
|
||||
const targetHealthBefore = target.health.value;
|
||||
const result = deadAttacker.useWeapon(weapon, target);
|
||||
return (
|
||||
result.weapon.health === weaponHPBefore &&
|
||||
result.weapon.health.value === weaponHPBefore &&
|
||||
result.target.health.value === targetHealthBefore
|
||||
);
|
||||
},
|
||||
@@ -110,11 +110,11 @@ describe('Magical Objects', () => {
|
||||
const thief = Character.create({ name: 'thief', level: Level.create(1) });
|
||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner });
|
||||
const weaponHPBefore = weapon.health;
|
||||
const weaponHPBefore = weapon.health.value;
|
||||
const targetHealthBefore = target.health.value;
|
||||
const result = thief.useWeapon(weapon, target);
|
||||
return (
|
||||
result.weapon.health === weaponHPBefore &&
|
||||
result.weapon.health.value === weaponHPBefore &&
|
||||
result.target.health.value === targetHealthBefore
|
||||
);
|
||||
},
|
||||
@@ -136,7 +136,9 @@ describe('Magical Objects', () => {
|
||||
const targetHealthBefore = firstUse.target.health.value;
|
||||
// Try to use again on the destroyed weapon
|
||||
const result = owner.useWeapon(destroyedWeapon, firstUse.target);
|
||||
return result.weapon.health === 0 && result.target.health.value === targetHealthBefore;
|
||||
return (
|
||||
result.weapon.health.value === 0 && result.target.health.value === targetHealthBefore
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -203,7 +205,7 @@ describe('Magical Objects', () => {
|
||||
});
|
||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||
const result = character.useHealingObject(object, healAmount);
|
||||
return result.object.health === objectHP - healAmount;
|
||||
return result.object.health.value === objectHP - healAmount;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -255,11 +257,11 @@ describe('Magical Objects', () => {
|
||||
// Kill the character using a separate killer
|
||||
const killer = Character.create({ name: 'boss', level: Level.create(1) });
|
||||
const deadCharacter = killer.dealDamage(character, 10000);
|
||||
const objectHPBefore = object.health;
|
||||
const objectHPBefore = object.health.value;
|
||||
const characterHealthBefore = deadCharacter.health.value;
|
||||
const result = deadCharacter.useHealingObject(object, 100);
|
||||
return (
|
||||
result.object.health === objectHPBefore &&
|
||||
result.object.health.value === objectHPBefore &&
|
||||
result.character.health.value === characterHealthBefore
|
||||
);
|
||||
}),
|
||||
@@ -287,7 +289,8 @@ describe('Magical Objects', () => {
|
||||
// Try to use again on the destroyed object
|
||||
const result = healedCharacter.useHealingObject(destroyedObject, 100);
|
||||
return (
|
||||
result.object.health === 0 && result.character.health.value === characterHealthBefore
|
||||
result.object.health.value === 0 &&
|
||||
result.character.health.value === characterHealthBefore
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -305,7 +308,7 @@ describe('Magical Objects', () => {
|
||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||
const result = attacker.useWeapon(weapon, target);
|
||||
return result.weapon.health >= 0;
|
||||
return result.weapon.health.value >= 0;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -320,7 +323,7 @@ describe('Magical Objects', () => {
|
||||
const character = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||
const result = character.useHealingObject(object, healAmount);
|
||||
return result.object.health >= 0;
|
||||
return result.object.health.value >= 0;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -336,7 +339,7 @@ describe('Magical Objects', () => {
|
||||
const target = Character.create({ name: 'goblin', level: Level.create(1) });
|
||||
const weapon = MagicalWeapon.create({ damage, maxHealth: weaponHP, owner: attacker });
|
||||
const result = attacker.useWeapon(weapon, target);
|
||||
return result.weapon.health <= weaponHP;
|
||||
return result.weapon.health.value <= weaponHP;
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -351,7 +354,7 @@ describe('Magical Objects', () => {
|
||||
const character = Character.create({ name: 'hero', level: Level.create(1) });
|
||||
const object = HealingObject.create({ maxHealth: objectHP, currentHealth: objectHP });
|
||||
const result = character.useHealingObject(object, healAmount);
|
||||
return result.object.health <= objectHP;
|
||||
return result.object.health.value <= objectHP;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
*/
|
||||
|
||||
import { Character } from '../characters/Character.ts';
|
||||
import { Health } from '../value-objects/Health.ts';
|
||||
import { Level } from '../value-objects/Level.ts';
|
||||
import { MagicalObject } from './MagicalObject.ts';
|
||||
import type { Healer } from './magical-object-types.ts';
|
||||
|
||||
export class HealingObject extends MagicalObject implements Healer {
|
||||
private constructor(
|
||||
health: number,
|
||||
maxHealth: number,
|
||||
health: Health,
|
||||
maxHealth: Health,
|
||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||
) {
|
||||
super(health, maxHealth, status);
|
||||
@@ -32,7 +33,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
||||
if (currentHealth > maxHealth) throw new Error('CurrentHealth cannot exceed maxHealth');
|
||||
const status =
|
||||
currentHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||
return new HealingObject(currentHealth, maxHealth, status);
|
||||
return new HealingObject(Health.create(currentHealth), Health.create(maxHealth), status);
|
||||
}
|
||||
|
||||
/** Use this object to heal a character. Returns updated object and character. */
|
||||
@@ -44,7 +45,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
||||
// Negative amount is invalid
|
||||
if (amount < 0) throw new Error('Heal amount must be non-negative');
|
||||
// Calculate actual heal amount: min of requested, object remaining, character headroom
|
||||
const objectRemaining = this.health;
|
||||
const objectRemaining = this.health.value;
|
||||
const characterMax = Level.maxHealthForLevel(character.level.value);
|
||||
const characterHeadroom = characterMax - character.health.value;
|
||||
const actualHeal = Math.min(amount, objectRemaining, characterHeadroom);
|
||||
@@ -53,7 +54,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
||||
return { object: this, character };
|
||||
}
|
||||
// Create updated object
|
||||
const newObjectHealth = this.health - actualHeal;
|
||||
const newObjectHealth = this.health.value - actualHeal;
|
||||
const newObjectStatus =
|
||||
newObjectHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||
// Create updated character
|
||||
@@ -64,7 +65,7 @@ export class HealingObject extends MagicalObject implements Healer {
|
||||
health: newCharacterHealth,
|
||||
});
|
||||
return {
|
||||
object: new HealingObject(newObjectHealth, this.maxHealth, newObjectStatus),
|
||||
object: new HealingObject(Health.create(newObjectHealth), this.maxHealth, newObjectStatus),
|
||||
character: newCharacter,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,24 +7,26 @@
|
||||
* - Status derived from health (0 = destroyed, > 0 = alive)
|
||||
*/
|
||||
|
||||
import { Health } from '../value-objects/Health.ts';
|
||||
|
||||
export type MagicalObjectStatus = { readonly kind: 'alive' } | { readonly kind: 'destroyed' };
|
||||
|
||||
export class MagicalObject {
|
||||
readonly #health: number;
|
||||
readonly #maxHealth: number;
|
||||
readonly #health: Health;
|
||||
readonly #maxHealth: Health;
|
||||
readonly #status: MagicalObjectStatus;
|
||||
|
||||
protected constructor(health: number, maxHealth: number, status: MagicalObjectStatus) {
|
||||
protected constructor(health: Health, maxHealth: Health, status: MagicalObjectStatus) {
|
||||
this.#health = health;
|
||||
this.#maxHealth = maxHealth;
|
||||
this.#status = status;
|
||||
}
|
||||
|
||||
get health(): number {
|
||||
get health(): Health {
|
||||
return this.#health;
|
||||
}
|
||||
|
||||
get maxHealth(): number {
|
||||
get maxHealth(): Health {
|
||||
return this.#maxHealth;
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ export class MagicalObject {
|
||||
/** Create a destroyed object (health = 0). */
|
||||
static createDestroyed(maxHealth: number): MagicalObject {
|
||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||
return new MagicalObject(0, maxHealth, { kind: 'destroyed' });
|
||||
return new MagicalObject(Health.create(0), Health.create(maxHealth), { kind: 'destroyed' });
|
||||
}
|
||||
|
||||
/** Check if this object is alive. */
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* - Damage is non-negative
|
||||
*/
|
||||
import { Character } from '../characters/Character.ts';
|
||||
import { Health } from '../value-objects/Health.ts';
|
||||
import { MagicalObject } from './MagicalObject.ts';
|
||||
import type { DamageDealer } from './magical-object-types.ts';
|
||||
|
||||
@@ -14,8 +15,8 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
readonly #owner: Character;
|
||||
|
||||
private constructor(
|
||||
health: number,
|
||||
maxHealth: number,
|
||||
health: Health,
|
||||
maxHealth: Health,
|
||||
status: { readonly kind: 'alive' } | { readonly kind: 'destroyed' },
|
||||
damage: number,
|
||||
owner: Character,
|
||||
@@ -36,7 +37,13 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
}): MagicalWeapon {
|
||||
if (maxHealth < 0) throw new Error('MaxHealth cannot be negative');
|
||||
if (damage < 0) throw new Error('Damage cannot be negative');
|
||||
return new MagicalWeapon(maxHealth, maxHealth, { kind: 'alive' }, damage, owner);
|
||||
return new MagicalWeapon(
|
||||
Health.create(maxHealth),
|
||||
Health.create(maxHealth),
|
||||
{ kind: 'alive' },
|
||||
damage,
|
||||
owner,
|
||||
);
|
||||
}
|
||||
|
||||
get damage(): number {
|
||||
@@ -63,12 +70,12 @@ export class MagicalWeapon extends MagicalObject implements DamageDealer {
|
||||
status: newTargetStatus,
|
||||
});
|
||||
// Reduce weapon health by 1
|
||||
const newWeaponHealth = this.health - 1;
|
||||
const newWeaponHealth = this.health.value - 1;
|
||||
const newWeaponStatus =
|
||||
newWeaponHealth === 0 ? { kind: 'destroyed' as const } : { kind: 'alive' as const };
|
||||
return {
|
||||
weapon: new MagicalWeapon(
|
||||
newWeaponHealth,
|
||||
Health.create(newWeaponHealth),
|
||||
this.maxHealth,
|
||||
newWeaponStatus,
|
||||
this.#damage,
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
* so Character can depend on abstractions rather than concrete classes.
|
||||
*/
|
||||
import type { Character } from '../characters/Character.ts';
|
||||
import type { Health } from '../value-objects/Health.ts';
|
||||
import type { MagicalObjectStatus } from './MagicalObject.ts';
|
||||
|
||||
/** A magical object that deals damage — from Character's point of view */
|
||||
export interface DamageDealer {
|
||||
readonly owner: Character;
|
||||
readonly health: number;
|
||||
readonly health: Health;
|
||||
readonly status: MagicalObjectStatus;
|
||||
use(target: Character): { weapon: DamageDealer; target: Character };
|
||||
}
|
||||
|
||||
/** A magical object that heals — from Character's point of view */
|
||||
export interface Healer {
|
||||
readonly health: number;
|
||||
readonly health: Health;
|
||||
readonly status: MagicalObjectStatus;
|
||||
heal(character: Character, amount: number): { object: Healer; character: Character };
|
||||
}
|
||||
|
||||
+10235
-1379
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+10235
-1379
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user