gcf-aif-ext 0.7.14 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/commands/gcf.js CHANGED
@@ -731,6 +731,27 @@ function registerItem(gcf) {
731
731
  });
732
732
  }
733
733
 
734
+ function registerDepends(gcf) {
735
+ gcf
736
+ .command('depends <id>')
737
+ .description(
738
+ 'Change the items it waits for: refuses an unknown or killed id and a cycle (commit)',
739
+ )
740
+ .option('--add <ids>', 'ids to add, comma-separated')
741
+ .option('--remove <ids>', 'ids to remove, comma-separated')
742
+ .option('--no-commit', 'skip the commit (tests only)')
743
+ .action(async (id, o) => {
744
+ const r = await (
745
+ await lib('work-ops')
746
+ ).setDepends(process.cwd(), id, {
747
+ add: splitList(o.add),
748
+ remove: splitList(o.remove),
749
+ commit: o.commit,
750
+ });
751
+ console.log(`${id} depends on ${r.depends.join(', ') || 'nothing'}${shortHash(r.hash)}`);
752
+ });
753
+ }
754
+
734
755
  function registerItemMoves(gcf) {
735
756
  const ops = () => lib('work-ops');
736
757
  gcf
@@ -741,6 +762,7 @@ function registerItemMoves(gcf) {
741
762
  const r = await (await ops()).prioritizeItems(process.cwd(), ids, { commit: o.commit });
742
763
  console.log(`ranked ${r.ids.join(', ')}${shortHash(r.hash)}`);
743
764
  });
765
+ registerDepends(gcf);
744
766
  const question = gcf.command('question').description('Open questions (gcf/open-questions.md)');
745
767
  question
746
768
  .command('answer <qid>')
@@ -861,6 +883,26 @@ async function inboxMove(commit, message, edit) {
861
883
  });
862
884
  }
863
885
 
886
+ function registerInboxTag(inbox) {
887
+ inbox
888
+ .command('tag <in-id>')
889
+ .description('Add tags to an entry and remove others')
890
+ .option('--add <tags>', 'tags to add, comma-separated')
891
+ .option('--remove <tags>', 'tags to remove, comma-separated')
892
+ .option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
893
+ .action(async (id, o) => {
894
+ const { tagInbox } = await lib('inbox');
895
+ let tags = [];
896
+ const hash = await inboxMove(o.commit, `gcf(inbox): tag ${id}`, async (root) => {
897
+ ({ tags } = await tagInbox(root, id, {
898
+ add: splitList(o.add),
899
+ remove: splitList(o.remove),
900
+ }));
901
+ });
902
+ console.log(`${id} tags: ${tags.join(', ') || 'none'}${shortHash(hash)}`);
903
+ });
904
+ }
905
+
864
906
  function registerInbox(gcf) {
865
907
  const inbox = gcf
866
908
  .command('inbox')
@@ -874,8 +916,9 @@ function registerInbox(gcf) {
874
916
  ? l
875
917
  .map((e) => {
876
918
  const source = e.kind ? `lesson:${e.kind}:${e.item}` : e.source;
919
+ const tags = e.tags.length ? ` (tags: ${e.tags.join(', ')})` : '';
877
920
 
878
- return `${e.id} [${source}] ${e.text}`;
921
+ return `${e.id} [${source}] ${e.text}${tags}`;
879
922
  })
880
923
  .join('\n')
881
924
  : 'inbox empty',
@@ -888,6 +931,7 @@ function registerInbox(gcf) {
888
931
  // A text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of
889
932
  // ai-factory's commands, `--` included: as `--text=<text>` it stays one value.
890
933
  .option('--text <text>', 'the entry, when it starts with a hyphen')
934
+ .option('--tags <tags>', 'its tags, comma-separated: idea, later, ui:hud')
891
935
  .option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
892
936
  .action(async (arg, o) => {
893
937
  const text = o.text ?? arg;
@@ -896,10 +940,11 @@ function registerInbox(gcf) {
896
940
  const { addInbox } = await lib('inbox');
897
941
  let id = '';
898
942
  const hash = await inboxMove(o.commit, 'gcf(inbox): add', async (root) => {
899
- id = (await addInbox(root, text, o.source)).id;
943
+ id = (await addInbox(root, text, o.source, { tags: splitList(o.tags) })).id;
900
944
  });
901
945
  console.log(`${id}${shortHash(hash)}`);
902
946
  });
947
+ registerInboxTag(inbox);
903
948
  inbox
904
949
  .command('drop <in-id>')
905
950
  .description('Erase an entry: triaged as noise, a duplicate, or turned into an item')
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.7.14",
4
+ "version": "0.8.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
@@ -0,0 +1,22 @@
1
+ // The language AI Factory sets in `.ai-factory/config.yaml` for what an agent writes to the
2
+ // human: `language.ui`. GCF adds no setting of its own (Methodology §5.2.3); agents read the
3
+ // file themselves, the core reads it only for the prompts it writes.
4
+ import { readFile } from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import YAML from 'yaml';
7
+
8
+ /**
9
+ * The project's `language.ui`, `en` by default. A missing or unreadable file gives `en`: the
10
+ * language is a preference, and a broken one must not stop the work.
11
+ */
12
+ export async function uiLanguage(projectDir) {
13
+ try {
14
+ const file = path.join(path.resolve(projectDir), '.ai-factory', 'config.yaml');
15
+ const ui = YAML.parse(await readFile(file, 'utf8'))?.language?.ui;
16
+
17
+ // A language name or code, one word: anything else would break the prompt line it goes in.
18
+ return typeof ui === 'string' && /^[\w-]{2,32}$/.test(ui.trim()) ? ui.trim() : 'en';
19
+ } catch {
20
+ return 'en';
21
+ }
22
+ }
package/lib/board.js CHANGED
@@ -134,6 +134,26 @@ async function phaseOf(projectDir) {
134
134
  }
135
135
  }
136
136
 
137
+ /**
138
+ * The AI Factory extensions the project has, name → version, from `.ai-factory.json`: the board
139
+ * shows which framework packages a project runs. `{}` when the file is missing or unreadable.
140
+ */
141
+ async function extensionsOf(projectDir) {
142
+ try {
143
+ const cfg = JSON.parse(
144
+ await readFile(path.join(path.resolve(projectDir), '.ai-factory.json'), 'utf8'),
145
+ );
146
+
147
+ return Object.fromEntries(
148
+ (cfg.extensions ?? [])
149
+ .filter((e) => typeof e?.name === 'string')
150
+ .map((e) => [e.name, typeof e.version === 'string' ? e.version : null]),
151
+ );
152
+ } catch {
153
+ return {};
154
+ }
155
+ }
156
+
137
157
  /** The release block with every field: one a malformed block lacks is null (live check says so). */
138
158
  const releaseOf = (r) =>
139
159
  r && {
@@ -274,6 +294,9 @@ export async function board(projectDir) {
274
294
  const inbox = await listInbox(root);
275
295
  // Where agents struggle: the lessons in the inbox each item's sessions left.
276
296
  for (const i of items) i.lessons = inbox.filter((e) => e.item === i.id).length;
297
+ // The reverse of depends: the items on the board that wait for this one.
298
+ for (const i of items)
299
+ i.needed_by = items.filter((o) => o.depends.includes(i.id)).map((o) => o.id);
277
300
 
278
301
  const config = await readConfig(root);
279
302
  const live = await readLive(root);
@@ -291,6 +314,7 @@ export async function board(projectDir) {
291
314
  compact_pct: config.auto_compact_pct,
292
315
  },
293
316
  phase: await phaseOf(projectDir),
317
+ extensions: await extensionsOf(projectDir),
294
318
  // A malformed block reads with fields missing (live check names it): null, not absent.
295
319
  release: releaseOf(live.release),
296
320
  columns: Object.fromEntries(
@@ -557,10 +557,10 @@ function ledgerIssues(ledger) {
557
557
  }
558
558
 
559
559
  /**
560
- * M2 over `depends:` of units and items together: a cycle never gets played — every id in it
561
- * waits for the next one. Each cycle is reported once, from the id DFS meets it at.
560
+ * `depends:` of every unit and item: id → the ids it waits for. A feature also waits for its
561
+ * children: it closes only after them, so a child that depends on it never gets played.
562
562
  */
563
- export function dependsCycles(ledger) {
563
+ export function dependsGraph(ledger) {
564
564
  /** @type {Map<string, string[]>} */
565
565
  const graph = new Map();
566
566
  for (const u of ledger.design.flatMap((d) => d.units)) {
@@ -568,7 +568,43 @@ export function dependsCycles(ledger) {
568
568
  graph.set(u.id, Array.isArray(list) ? list : []);
569
569
  }
570
570
 
571
- for (const c of ledger.items) graph.set(String(c.data.id), c.data.depends ?? []);
571
+ for (const c of ledger.items) {
572
+ const children = c.data.type === 'feature' ? (c.data.children ?? []) : [];
573
+ graph.set(String(c.data.id), [...(c.data.depends ?? []), ...children]);
574
+ }
575
+
576
+ return graph;
577
+ }
578
+
579
+ /** The ids from `from` to `to` over `graph`, both included, or null when there is no way. */
580
+ export function pathTo(graph, from, to, seen = new Set()) {
581
+ if (from === to) return [to];
582
+ if (seen.has(from)) return null;
583
+ seen.add(from);
584
+ for (const next of graph.get(from) ?? []) {
585
+ const rest = pathTo(graph, next, to, seen);
586
+ if (rest) return [from, ...rest];
587
+ }
588
+
589
+ return null;
590
+ }
591
+
592
+ /** Why a depends `cycle` (its ids, the first again at the end) never gets played. */
593
+ export function cycleMessage(ledger, cycle) {
594
+ const feature = cycle.some((id) => ledger.item(id)?.data.type === 'feature');
595
+
596
+ return (
597
+ `depends cycle ${cycle.join(' -> ')}: none of them can be played first` +
598
+ (feature ? ' (a feature waits for its children)' : '')
599
+ );
600
+ }
601
+
602
+ /**
603
+ * M2 over `depends:` of units and items together: a cycle never gets played — every id in it
604
+ * waits for the next one. Each cycle is reported once, from the id DFS meets it at.
605
+ */
606
+ export function dependsCycles(ledger) {
607
+ const graph = dependsGraph(ledger);
572
608
  /** @type {Issue[]} */
573
609
  const out = [];
574
610
  const state = new Map(); // id → 'open' while on the DFS path, 'done' after
@@ -583,7 +619,7 @@ export function dependsCycles(ledger) {
583
619
  file,
584
620
  line: 1,
585
621
  id,
586
- message: `depends cycle ${cycle.join(' -> ')}: none of them can be played first`,
622
+ message: cycleMessage(ledger, cycle),
587
623
  });
588
624
 
589
625
  return;
package/lib/inbox.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // gcf/inbox.md (Methodology §5.1): raw input waiting for triage — playtest notes, CI failures,
2
- // the agent's ideas and requests. One line per entry, `- <in-id> | <source> | <text>`; a triaged
3
- // line is erased, not marked: the file holds what is still waiting, not what happened to it.
2
+ // the agent's ideas and requests, the operator's ideas and notes. One line per entry,
3
+ // `- <in-id> | <source> | <text>[ | tags: a, b]`; a triaged line is erased, not marked: the file
4
+ // holds what is still waiting, not what happened to it. Tags sort it; an older core reads the
5
+ // tags field as part of the text, which loses nothing.
4
6
  import { readFile } from 'node:fs/promises';
5
7
  import { existsSync } from 'node:fs';
6
8
  import { createHash } from 'node:crypto';
@@ -8,48 +10,136 @@ import path from 'node:path';
8
10
  import { atomicWrite } from './safe-path.js';
9
11
 
10
12
  export const INBOX_HEAD =
11
- '# Inbox — `- <in-id> | <source> | <text>`, raw input waiting for triage; a triaged line ' +
12
- 'is erased\n';
13
+ '# Inbox — `- <in-id> | <source> | <text>[ | tags: a, b]`, raw input waiting for triage; a ' +
14
+ 'triaged line is erased\n';
13
15
  export const SOURCES = ['playtest', 'ci', 'agent', 'operator'];
14
16
  const LINE = /^- (in-[0-9a-f]+) \| (\S+) \| (.+)$/;
17
+ // The text may hold ` | ` itself: the tags are only the field at the very end.
18
+ const TAGS = / \| tags: ([^|]*)$/;
19
+ const TAG = /^[a-z0-9][a-z0-9:-]{0,39}$/;
15
20
  // An agent's lesson (lessons.js): `lesson:<kind>:<item-id>`.
16
21
  const LESSON = /^lesson:([a-z]+):([a-z0-9][a-z0-9-]*)$/;
17
22
 
18
23
  const inboxFile = (root) => path.join(root, 'inbox.md');
19
24
 
25
+ /**
26
+ * The text and the tags of a line's last field. A field that is not a clean tag list (a bad
27
+ * tag, a tag twice, no text before it) stays part of the text: nothing written is lost.
28
+ */
29
+ function textAndTags(rest) {
30
+ const m = rest.match(TAGS);
31
+ const tags = m ? m[1].split(',').map((t) => t.trim()) : [];
32
+ const text = m ? rest.slice(0, m.index).trim() : '';
33
+ const clean = tags.every((t) => TAG.test(t)) && new Set(tags).size === tags.length;
34
+ if (!m || !clean || !text) return { text: rest.trim(), tags: [] };
35
+
36
+ return { text, tags };
37
+ }
38
+
39
+ const lineOf = (e) =>
40
+ `- ${e.id} | ${e.source} | ${e.text}${e.tags.length ? ` | tags: ${e.tags.join(', ')}` : ''}`;
41
+
42
+ /** The entry of one line as written, or null. */
43
+ function rawOf(line) {
44
+ const m = line.match(LINE);
45
+
46
+ return m ? { id: m[1], source: m[2], ...textAndTags(m[3]) } : null;
47
+ }
48
+
49
+ /**
50
+ * One entry per id. A union merge of two branches that tagged an entry differently leaves two
51
+ * lines with its id: they read as one, its first line's text with the tags of both.
52
+ */
53
+ function mergeById(raws) {
54
+ /** @type {Map<string, {id: string, source: string, text: string, tags: string[]}>} */
55
+ const byId = new Map();
56
+ for (const r of raws) {
57
+ const seen = byId.get(r.id);
58
+ if (seen) seen.tags = [...new Set([...seen.tags, ...r.tags])];
59
+ else byId.set(r.id, { ...r });
60
+ }
61
+
62
+ return [...byId.values()];
63
+ }
64
+
20
65
  /**
21
66
  * Every entry; a lesson reads as source `lesson` with its `kind` and the `item` it came from.
22
- * @typedef {{id: string, source: string, text: string, kind?: string, item?: string}} Entry
67
+ * @typedef {{id: string, source: string, text: string, tags: string[], kind?: string,
68
+ * item?: string}} Entry
23
69
  * @returns {Promise<Entry[]>}
24
70
  */
25
71
  export async function listInbox(root) {
26
72
  const file = inboxFile(root);
27
73
  if (!existsSync(file)) return [];
74
+ const raws = (await readFile(file, 'utf8')).split(/\r?\n/).map(rawOf).filter(Boolean);
28
75
 
29
- return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
30
- const m = l.match(LINE);
31
- if (!m) return [];
32
- const lesson = m[2].match(LESSON);
33
- const entry = { id: m[1], source: m[2], text: m[3].trim() };
76
+ return mergeById(/** @type {any[]} */ (raws)).map((entry) => {
77
+ const lesson = entry.source.match(LESSON);
34
78
 
35
- return [lesson ? { ...entry, source: 'lesson', kind: lesson[1], item: lesson[2] } : entry];
79
+ return lesson ? { ...entry, source: 'lesson', kind: lesson[1], item: lesson[2] } : entry;
36
80
  });
37
81
  }
38
82
 
39
- /** Append an entry; its id hashes the text and the moment, so two branches never collide. */
40
- export async function addInbox(root, text, source = 'operator', now = new Date().toISOString()) {
83
+ /** Refuse a tag that is not a lowercase word (`idea`, `later`, `ui:hud`). */
84
+ function checkTags(tags) {
85
+ const bad = tags.find((t) => !TAG.test(t));
86
+ if (bad !== undefined)
87
+ throw new Error(`tag "${bad}" must be a lowercase word: letters, digits, - and :`);
88
+ if (new Set(tags).size !== tags.length) throw new Error('a tag is named twice');
89
+ }
90
+
91
+ /**
92
+ * Append an entry with its `tags`; its id hashes the text and the moment (`now`), so two
93
+ * branches never collide.
94
+ */
95
+ export async function addInbox(
96
+ root,
97
+ text,
98
+ source = 'operator',
99
+ { now = new Date().toISOString(), tags = [] } = {},
100
+ ) {
41
101
  const clean = String(text).replace(/\s+/g, ' ').trim();
42
102
  if (!clean) throw new Error('an inbox entry needs text');
43
103
  if (!SOURCES.includes(source) && !LESSON.test(source))
44
104
  throw new Error(`source must be ${SOURCES.join(' | ')}`);
105
+ if (textAndTags(clean).tags.length)
106
+ throw new Error('the text ends like a tags field: give tags as tags');
107
+ checkTags(tags);
45
108
  const id = `in-${createHash('sha1').update(`${now}\n${clean}`).digest('hex').slice(0, 8)}`;
46
109
  const file = inboxFile(root);
47
110
  const old = existsSync(file) ? await readFile(file, 'utf8') : INBOX_HEAD;
48
111
  const eol = old.includes('\r\n') ? '\r\n' : '\n';
49
112
  const head = old.endsWith('\n') ? old : old + eol;
50
- await atomicWrite(file, `${head}- ${id} | ${source} | ${clean}${eol}`);
113
+ const entry = { id, source, text: clean, tags };
114
+ await atomicWrite(file, `${head}${lineOf(entry)}${eol}`);
115
+
116
+ return entry;
117
+ }
118
+
119
+ /**
120
+ * Add tags to entry `id` and remove others; refuses one it has, or lacks, already. Lines a
121
+ * union merge doubled become one again, at the first one's place.
122
+ */
123
+ export async function tagInbox(root, id, { add = [], remove = [] }) {
124
+ if (!add.length && !remove.length) throw new Error('name tags to --add or --remove');
125
+ checkTags([...add, ...remove]);
126
+ const file = inboxFile(root);
127
+ const lines = (existsSync(file) ? await readFile(file, 'utf8') : '').split(/(?<=\n)/);
128
+ const bodyOf = (l) => l.replace(/\r?\n$/, '');
129
+ const mine = lines.flatMap((l, i) => (rawOf(bodyOf(l))?.id === id ? [i] : []));
130
+ if (!mine.length) throw new Error(`${id} is not in gcf/inbox.md`);
131
+ const [entry] = mergeById(mine.map((i) => /** @type {any} */ (rawOf(bodyOf(lines[i])))));
132
+ const had = add.filter((x) => entry.tags.includes(x));
133
+ if (had.length) throw new Error(`${id} is tagged ${had.join(', ')} already`);
134
+ const missing = remove.filter((x) => !entry.tags.includes(x));
135
+ if (missing.length) throw new Error(`${id} has no tag ${missing.join(', ')}`);
136
+ entry.tags = [...entry.tags.filter((x) => !remove.includes(x)), ...add];
137
+ // The line keeps its own ending: a CRLF file stays CRLF.
138
+ const first = lines[mine[0]];
139
+ lines[mine[0]] = `${lineOf(entry)}${first.slice(bodyOf(first).length)}`;
140
+ await atomicWrite(file, lines.filter((_, i) => !mine.slice(1).includes(i)).join(''));
51
141
 
52
- return { id, source, text: clean };
142
+ return entry;
53
143
  }
54
144
 
55
145
  /** Erase entry `id`; refuses an id the inbox does not hold. */
package/lib/lessons.js CHANGED
@@ -5,6 +5,7 @@
5
5
  // the session ends: the harness asks them in one more turn, `/gcf-run` asks them at its close.
6
6
  import { readFile } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
+ import { uiLanguage } from './aif-config.js';
8
9
  import { readRawConfig } from './gcf-paths.js';
9
10
  import { addInbox } from './inbox.js';
10
11
  import { isValidId } from './id.js';
@@ -66,6 +67,7 @@ function questionsOf(config) {
66
67
  export async function lessonsPrompt(projectDir, id, { reply = 'cli' } = {}) {
67
68
  if (!isValidId(id)) throw new Error(`${id} is not an item id`);
68
69
  const config = lessonsConfig(await readRawConfig(path.join(path.resolve(projectDir), 'gcf')));
70
+ const ui = await uiLanguage(projectDir);
69
71
  const asked = questionsOf(config)
70
72
  .map((q, i) => `${i + 1}. ${q.kind}: ${q.text}`)
71
73
  .join('\n');
@@ -83,6 +85,10 @@ export async function lessonsPrompt(projectDir, id, { reply = 'cli' } = {}) {
83
85
  'what got in your way, so it can be removed.',
84
86
  'Answer from this session only, one finding per entry, in a sentence or two; skip a ' +
85
87
  'question with nothing to say, and never invent one.',
88
+ // The operator reads the answers: they go in the project's language.ui (Methodology §5.2.3).
89
+ ...(ui === 'en'
90
+ ? []
91
+ : [`Write each finding in the language "${ui}"; keep the kinds as they are.`]),
86
92
  asked,
87
93
  // The harness's turn comes after the item's commit and its verified trailer: an edit then
88
94
  // would leave the checkout dirty or move the trailer off the tip.
@@ -104,7 +110,10 @@ function lessonOf(raw, i) {
104
110
  if (!text) throw new Error(`lesson ${i + 1} has no text`);
105
111
  const where = raw?.where ? String(raw.where).replace(/\s+/g, ' ').trim() : '';
106
112
 
107
- return { kind, text: where ? `${text} (${where})` : text };
113
+ // An agent's free text must not read as an inbox tags field, nor fail the whole batch on it.
114
+ const full = (where ? `${text} (${where})` : text).replace(/ \| tags:/g, ' / tags:');
115
+
116
+ return { kind, text: full };
108
117
  }
109
118
 
110
119
  /**
package/lib/unit-ops.js CHANGED
@@ -8,7 +8,7 @@ import path from 'node:path';
8
8
  import { serialize } from './frontmatter.js';
9
9
  import { CARD_TYPES, SLUG_RE, listOf } from './design.js';
10
10
  import { buildLedger } from './ledger.js';
11
- import { designCheck } from './design-check.js';
11
+ import { cycleMessage, designCheck, dependsGraph, pathTo } from './design-check.js';
12
12
  import { atomicWrite, withFileRollback } from './safe-path.js';
13
13
  import { writeIndex } from './index-gen.js';
14
14
  import { operationInProgress, commitPaths } from './git.js';
@@ -124,6 +124,20 @@ function featureFor(ledger, root, { id, unitIds, systems, today }) {
124
124
  return { file, text: serialize(data, `\nintent:\n- TBD: what ${id} gives the player\n`) };
125
125
  }
126
126
 
127
+ /**
128
+ * Refuse taking `units` under `feature` when one waits for the feature: the feature closes only
129
+ * after its children, so neither would ever be played.
130
+ */
131
+ function refuseFeatureCycle(ledger, feature, units) {
132
+ const graph = dependsGraph(ledger);
133
+ graph.set(feature, [...(graph.get(feature) ?? []), ...units.map((u) => u.id)]);
134
+ for (const u of units)
135
+ for (const d of graph.get(u.id) ?? []) {
136
+ const loop = pathTo(graph, d, u.id);
137
+ if (loop) throw new Error(cycleMessage(ledger, [u.id, ...loop]));
138
+ }
139
+ }
140
+
127
141
  /** A unit goes into work only ready: no TBD, no blocking D1, no blocking finding. */
128
142
  function refuseUnready(units, summaries) {
129
143
  for (const unit of units) {
@@ -163,6 +177,7 @@ export async function takeUnits(
163
177
  if (feature) {
164
178
  const systems = units.flatMap((u) => listOf(u.header.system)).filter((s) => s !== 'TBD');
165
179
  writes.push(featureFor(ledger, root, { id: feature, unitIds: ids, systems, today }));
180
+ refuseFeatureCycle(ledger, feature, units);
166
181
  }
167
182
 
168
183
  const paths = units.map((u) => basePaths(root, u));
@@ -37,9 +37,16 @@ export function codeTree(projectDir) {
37
37
  return r.stdout.trim();
38
38
  };
39
39
 
40
+ // `git add` fails on an exclude that names an ignored path (`.gcf-session` in .gitignore or
41
+ // info/exclude), tracked or not, and an ignored path stays out of the add anyway.
42
+ const ignored = git(projectDir, ['check-ignore', '--no-index', '--', ...NOT_CODE]).stdout.split(
43
+ /\r?\n/,
44
+ );
45
+ const excludes = NOT_CODE.filter((p) => !ignored.includes(p)).map((p) => `:(exclude)${p}`);
46
+
40
47
  try {
41
48
  if (existsSync(real)) copyFileSync(real, tmp);
42
- step(['add', '-A', '--', '.', ...NOT_CODE.map((p) => `:(exclude)${p}`)]);
49
+ step(['add', '-A', '--', '.', ...excludes]);
43
50
  step(['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...NOT_CODE]);
44
51
 
45
52
  return step(['write-tree']);
package/lib/work-ops.js CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  knownIssueOf,
37
37
  } from './live.js';
38
38
  import { dropInbox } from './inbox.js';
39
+ import { cycleMessage, dependsGraph, pathTo } from './design-check.js';
39
40
  import { refuseByPhase } from './phase.js';
40
41
  import { locked, runHolder } from './main-lock.js';
41
42
  import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
@@ -830,6 +831,31 @@ function runFields(spec) {
830
831
  );
831
832
  }
832
833
 
834
+ /**
835
+ * Refuse `depends` of item `id` that could never be played (Methodology §4.1): an `added` id
836
+ * that is the item itself, unknown, or in the graveyard; or a cycle back to the item. A new
837
+ * child is not in its feature's `children` yet: `parent` adds that edge.
838
+ */
839
+ function checkDepends(ledger, id, depends, { added = depends, parent = null } = {}) {
840
+ if (new Set(added).size !== added.length) throw new Error('a dependency is named twice');
841
+ if (added.includes(id)) throw new Error(`${id} cannot depend on itself`);
842
+ const unknown = added.filter((d) => !ledger.known(d));
843
+ if (unknown.length) throw new Error(`depends on unknown ${unknown.join(', ')}`);
844
+ const dead = added.filter((d) => {
845
+ const { live, terminal } = ledger.where(d);
846
+
847
+ return !live.length && terminal.includes('killed');
848
+ });
849
+ if (dead.length)
850
+ throw new Error(`${dead.join(', ')} ended in the graveyard: it will never be played`);
851
+ const graph = dependsGraph(ledger).set(id, depends);
852
+ if (parent) graph.set(parent, [...(graph.get(parent) ?? []), id]);
853
+ for (const d of added) {
854
+ const loop = pathTo(graph, d, id);
855
+ if (loop) throw new Error(cycleMessage(ledger, [id, ...loop]));
856
+ }
857
+ }
858
+
833
859
  /** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
834
860
  function checkNew(ledger, spec) {
835
861
  const { id, type, gate, severity } = spec;
@@ -844,6 +870,7 @@ function checkNew(ledger, spec) {
844
870
  if (type === 'bug' && !SEVERITIES.includes(severity))
845
871
  throw new Error(`a bug needs --severity ${SEVERITIES.join('|')}`);
846
872
  checkRelease(spec);
873
+ checkDepends(ledger, id, spec.depends ?? [], { parent: spec.parent });
847
874
 
848
875
  return g;
849
876
  }
@@ -949,6 +976,42 @@ async function prioritizeItemsUnlocked(projectDir, ids, { commit = true } = {})
949
976
  return { ids, hash };
950
977
  }
951
978
 
979
+ /** `before` with `add` added and `remove` taken out; refuses an id named twice or out of place. */
980
+ function changedDepends(id, before, add, remove) {
981
+ const twice = [...add, ...remove].find((d, i, all) => all.indexOf(d) !== i);
982
+ if (twice) throw new Error(`${twice} is named twice`);
983
+ const listed = add.filter((d) => before.includes(d));
984
+ if (listed.length) throw new Error(`${id} already depends on ${listed.join(', ')}`);
985
+ const absent = remove.filter((d) => !before.includes(d));
986
+ if (absent.length) throw new Error(`${id} does not depend on ${absent.join(', ')}`);
987
+
988
+ return [...before.filter((d) => !remove.includes(d)), ...add];
989
+ }
990
+
991
+ /**
992
+ * Add ids to item `id`'s `depends` and remove others. One commit `gcf(depends)`. An added id
993
+ * must be one that can be played and close no cycle; a removed one may be anything listed, an
994
+ * unknown or a killed one included: removing it is how such a dependency gets fixed.
995
+ */
996
+ async function setDependsUnlocked(projectDir, id, { add = [], remove = [], commit = true } = {}) {
997
+ if (!add.length && !remove.length) throw new Error('name ids to --add or --remove');
998
+ const ledger = await buildLedger(projectDir);
999
+ const item = ledger.item(id);
1000
+ if (!item) throw new Error(`${id} is not a work item`);
1001
+ refuseRunning(projectDir, id);
1002
+ const depends = changedDepends(id, item.data.depends ?? [], add, remove);
1003
+ checkDepends(ledger, id, depends, { added: add });
1004
+ const hash = await commitMove(projectDir, `gcf(depends): ${id}`, {
1005
+ files: [item.file],
1006
+ // The item's own edits ride along, as with the §4.1 moves: a skill sets depends after it.
1007
+ inputs: [item.file],
1008
+ mutate: () => atomicWrite(item.file, serialize({ ...item.data, depends }, item.body)),
1009
+ commit,
1010
+ });
1011
+
1012
+ return { id, depends, hash };
1013
+ }
1014
+
952
1015
  /**
953
1016
  * A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
954
1017
  * not here: its body is the unit's slots. `from` erases the inbox entry it came from.
@@ -1012,6 +1075,9 @@ export const dropItem = (projectDir, id, opts = {}) =>
1012
1075
  /** @type {typeof prioritizeItemsUnlocked} */
1013
1076
  export const prioritizeItems = (projectDir, ids, opts = {}) =>
1014
1077
  locked(projectDir, opts.commit ?? true, () => prioritizeItemsUnlocked(projectDir, ids, opts));
1078
+ /** @type {typeof setDependsUnlocked} */
1079
+ export const setDepends = (projectDir, id, opts = {}) =>
1080
+ locked(projectDir, opts.commit ?? true, () => setDependsUnlocked(projectDir, id, opts));
1015
1081
  /** @type {typeof newItemUnlocked} */
1016
1082
  export const newItem = (projectDir, spec) =>
1017
1083
  locked(projectDir, spec.commit ?? false, () => newItemUnlocked(projectDir, spec));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.7.14",
3
+ "version": "0.8.0",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,8 +52,9 @@ workflow:
52
52
  17. write the item's `live:` delta (a feature's children keep `none: parent`; the feature writes the capability), run `ai-factory gcf design check`, and commit
53
53
  18. when the work is several items (a feature's children, or a batch), cut them along subsystem boundaries: each a thin slice that builds and tests alone
54
54
  19. two items of one subsystem that change different files get `parallel: ok`, and their `contract:` or `intent:` names those files
55
- 20. rank the items with `ai-factory gcf prioritize <id> <id> …`: dependencies first, then by value
56
- 21. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
55
+ 20. set each item's dependencies with `ai-factory gcf depends <id> --add <ids>`: it commits the item's edits too, and a feature's child never depends on its feature
56
+ 21. rank the items with `ai-factory gcf prioritize <id> <id> …`: dependencies first, then by value
57
+ 22. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
57
58
 
58
59
  invocation:
59
60
  - run when work starts on a unit or a group of units, when non-design work needs an item, or when a fresh idea needs a home
@@ -18,7 +18,7 @@ forbidden_behaviors:
18
18
  - do not create an item or erase an entry without the human's yes: the agent proposes, the human approves
19
19
  - do not turn a feel complaint into a bug: "jump feels floaty" is a tune of the item that owns the jump, a bug is behaviour that breaks a rule or a test
20
20
  - do not open a second item for something `gcf/graveyard.md` already buried: name the line and ask whether the reason still holds
21
- - do not edit `gcf/inbox.md` by hand: `ai-factory gcf item new --from` and `ai-factory gcf inbox drop` own its lines
21
+ - do not edit `gcf/inbox.md` by hand: `ai-factory gcf item new --from`, `ai-factory gcf inbox tag` and `ai-factory gcf inbox drop` own its lines
22
22
 
23
23
  workflow:
24
24
  1. run `ai-factory gcf inbox --json`; read `gcf/LIVE.md` and `gcf/INDEX.md` to know what exists and what is in work
@@ -30,7 +30,8 @@ workflow:
30
30
  7. a tune of an item waiting for its verdict or unplayed: leave the entry in the inbox, the note's only copy, until `/gcf-verdict <id> tune --note` records it and drops it; a tune of a kept mechanic is new work with `--reopens <id>`
31
31
  8. an idea for the design: write a unit with `TBD` slots in `gcf/design/<system>.md` and its backlog line, then run `ai-factory gcf inbox drop <in-id>`
32
32
  9. a duplicate or noise: run `ai-factory gcf inbox drop <in-id>`
33
- 10. report what each entry became and how many entries remain
33
+ 10. an entry the human keeps for later (an idea, a note): tag it with the tag the human names, `ai-factory gcf inbox tag <in-id> --add <tags>`, and leave it in the inbox
34
+ 11. report what each entry became and how many entries remain
34
35
 
35
36
  invocation:
36
37
  - run after a playtest session, after a CI run that added entries, or when the user asks to triage
@@ -1,42 +1,45 @@
1
- gcf_context:
2
- - the game's design and its work live in `gcf/`, and the `ai-factory gcf` commands and the gcf skills change them
3
- - read `gcf/INDEX.md` before anything else under `gcf/`: it lists every subsystem, design unit and work item
4
- - `gcf/LIVE.md` says what the game does now, and `gcf/PILLARS.md` says what it must stay
5
- - `ai-factory gcf board --json` gives the whole state in one read: the phase, the items by status with plan progress, the inbox and the open questions
6
-
7
- gcf_work:
8
- - every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
9
- - a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later; a text that starts with a hyphen goes as `inbox add --text="<text>"`
10
- - before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
11
- - drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext and the agent is Claude Code (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
12
- - a question about how the runs go (the share that reached their move, cost, plan checks, DoD attempts, guard firings) is answered by `ai-factory gcf-harness metrics`
13
- - after the code, write the item's `live:` delta and run `ai-factory gcf verify <id>`: fix what it reports and run it again until every check is green, at most `dod_attempts` of `gcf/config.yml` red runs; still red: block the item with the diagnosis
14
- - `submit` and `close` refuse code that changed since its last green `gcf verify`: run it again after any fix; `--unverified "<why>"` is the human's call, never the agent's
15
- - then end the work on the item with its command: `ai-factory gcf submit <id>` for gate verdict or decision, `ai-factory gcf close <id>` for gate auto
16
- - work that cannot go on: run `ai-factory gcf block <id> --reason "<why or the question>"`
17
- - in a worktree on branch `work/<id>` change no `gcf/` file but the item's `live:`, commit all, then run `ai-factory gcf verify <id> --mark`: `ai-factory gcf integrate` in the main checkout merges it and records the move
18
- - a design question only the human can answer becomes a D1 record in `gcf/open-questions.md`
19
- - a word added to a `not:` of `gcf/glossary.md` leaves `gcf/design/` and every item but an unplayed one in the same commit: `ai-factory gcf design check` names each hit as M4
20
- - the human plays from `ai-factory gcf playtest session`; each free note from that play goes to `ai-factory gcf inbox add --source playtest "<note>"`
21
- - a release item carries `version:` and `channel:` (`item new <id> --type release --release <v> --channel <c>`): its end writes the `release:` block of `gcf/LIVE.md`
22
- - `item new` and `take` refuse a type the current phase does not take: run `ai-factory gcf phase` and bring the refusal to the human
23
- - an item the human wants run on a given model or effort gets `item new <id> --model <m> --effort <e>`: the harness run passes both to the agent
24
- - ready items run in `priority:` order, lower first: rank a set of items with `ai-factory gcf prioritize <id> <id> …`, and put an id in `depends` only when the item uses that one's data or code
25
- - a D1 record that carries the human's `answer:` goes into the slot of each unit it blocks, with `answer: q-NNN`, and then leaves `gcf/open-questions.md`
26
- - a pillar changes only on the human's word: `ai-factory gcf pillar rename <from> <to>`, or `ai-factory gcf pillar retire <id> --reason "<why>"`
27
-
28
- gcf_forbidden:
29
- - do not edit `status:`, `frozen:`, `gcf/LIVE.md`, `gcf/history.md`, `gcf/graveyard.md` or `gcf/INDEX.md` by hand: the commands own them
30
- - do not give a verdict or a decision: `verdict` and `decide` belong to the human, through the gcf-verdict skill
31
- - do not answer a `TBD` of a design unit yourself: ask the human and offer your draft as the proposed answer
32
- - do not run `ai-factory gcf phase next`: the phase moves on the human's word only
33
- - do not change what agent runs may do: `ai-factory gcf permissions` and its deny groups belong to the human
34
- - do not change a setting of `gcf/config.yml`: `ai-factory gcf config set` belongs to the human
35
- - do not reuse an ended id: rework gets a new id with `reopens: <old id>`
36
- - do not commit past the gate with `--no-verify`: a refused commit names what to fix
37
-
38
- gcf_skills:
39
- - gcf-vision starts a game that has no design yet, and gcf-ingest imports a GDD into `gcf/design/`
40
- - gcf-card puts work into `gcf/work/`, and gcf-triage sorts `gcf/inbox.md`
41
- - gcf-verdict records the human's verdict or decision, and gcf-audit reports the health of `gcf/`
42
- - gcf-debate stresses the design, gcf-adopt puts existing code on GCF, and gcf-init sets up `gcf/` and revises the pillars
1
+ gcf_context:
2
+ - the game's design and its work live in `gcf/`, and the `ai-factory gcf` commands and the gcf skills change them
3
+ - read `gcf/INDEX.md` before anything else under `gcf/`: it lists every subsystem, design unit and work item
4
+ - `gcf/LIVE.md` says what the game does now, and `gcf/PILLARS.md` says what it must stay
5
+ - `ai-factory gcf board --json` gives the whole state in one read: the phase, the items by status with plan progress, the inbox and the open questions
6
+
7
+ gcf_work:
8
+ - every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
9
+ - a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>" [--tags <a,b>]`, and the gcf-triage skill sorts it later; a text that starts with a hyphen goes as `inbox add --text="<text>"`
10
+ - before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
11
+ - drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext and the agent is Claude Code (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
12
+ - a question about how the runs go (the share that reached their move, cost, plan checks, DoD attempts, guard firings) is answered by `ai-factory gcf-harness metrics`
13
+ - after the code, write the item's `live:` delta and run `ai-factory gcf verify <id>`: fix what it reports and run it again until every check is green, at most `dod_attempts` of `gcf/config.yml` red runs; still red: block the item with the diagnosis
14
+ - `submit` and `close` refuse code that changed since its last green `gcf verify`: run it again after any fix; `--unverified "<why>"` is the human's call, never the agent's
15
+ - then end the work on the item with its command: `ai-factory gcf submit <id>` for gate verdict or decision, `ai-factory gcf close <id>` for gate auto
16
+ - work that cannot go on: run `ai-factory gcf block <id> --reason "<why or the question>"`
17
+ - in a worktree on branch `work/<id>` change no `gcf/` file but the item's `live:`, commit all, then run `ai-factory gcf verify <id> --mark`: `ai-factory gcf integrate` in the main checkout merges it and records the move
18
+ - a design question only the human can answer becomes a D1 record in `gcf/open-questions.md`
19
+ - a word added to a `not:` of `gcf/glossary.md` leaves `gcf/design/` and every item but an unplayed one in the same commit: `ai-factory gcf design check` names each hit as M4
20
+ - the human plays from `ai-factory gcf playtest session`; each free note from that play goes to `ai-factory gcf inbox add --source playtest "<note>"`
21
+ - a release item carries `version:` and `channel:` (`item new <id> --type release --release <v> --channel <c>`): its end writes the `release:` block of `gcf/LIVE.md`
22
+ - `item new` and `take` refuse a type the current phase does not take: run `ai-factory gcf phase` and bring the refusal to the human
23
+ - an item the human wants run on a given model or effort gets `item new <id> --model <m> --effort <e>`: the harness run passes both to the agent
24
+ - ready items run in `priority:` order, lower first: rank a set of items with `ai-factory gcf prioritize <id> <id> …`, and put an id in `depends` only when the item uses that one's data or code: `ai-factory gcf depends <id> --add <ids>` or `--remove <ids>`, never a hand edit
25
+ - write what the human reads (a question, an assumption, a block reason, an inbox entry, a lesson, a report, an item's `title:`) in `language.ui` of `.ai-factory/config.yaml`, and the body of an item that is not a design unit in its `language.artifacts`
26
+ - write in English whatever lands in a checked document: `gcf/design/`, `gcf/systems/`, `gcf/glossary.md`, `gcf/PILLARS.md`, `gcf/LIVE.md`, the body of a design unit's item but its `tuning:` and `notes:`, an item's `live:` summary; an answer that moves into a unit's slot goes there in English
27
+ - never translate keys, statuses, ids, commands, `TBD`, `answer: q-NNN`, `## Handoff`, a `quote:` (it is verbatim from the GDD) or the fixed start of a block reason (`needs permission:`, `plan did not converge:`, `waits for`)
28
+ - a D1 record that carries the human's `answer:` goes into the slot of each unit it blocks, with `answer: q-NNN`, and then leaves `gcf/open-questions.md`
29
+ - a pillar changes only on the human's word: `ai-factory gcf pillar rename <from> <to>`, or `ai-factory gcf pillar retire <id> --reason "<why>"`
30
+
31
+ gcf_forbidden:
32
+ - do not edit `status:`, `frozen:`, `gcf/LIVE.md`, `gcf/history.md`, `gcf/graveyard.md` or `gcf/INDEX.md` by hand: the commands own them
33
+ - do not give a verdict or a decision: `verdict` and `decide` belong to the human, through the gcf-verdict skill
34
+ - do not answer a `TBD` of a design unit yourself: ask the human and offer your draft as the proposed answer
35
+ - do not run `ai-factory gcf phase next`: the phase moves on the human's word only
36
+ - do not change what agent runs may do: `ai-factory gcf permissions` and its deny groups belong to the human
37
+ - do not change a setting of `gcf/config.yml`: `ai-factory gcf config set` belongs to the human
38
+ - do not reuse an ended id: rework gets a new id with `reopens: <old id>`
39
+ - do not commit past the gate with `--no-verify`: a refused commit names what to fix
40
+
41
+ gcf_skills:
42
+ - gcf-vision starts a game that has no design yet, and gcf-ingest imports a GDD into `gcf/design/`
43
+ - gcf-card puts work into `gcf/work/`, and gcf-triage sorts `gcf/inbox.md`
44
+ - gcf-verdict records the human's verdict or decision, and gcf-audit reports the health of `gcf/`
45
+ - gcf-debate stresses the design, gcf-adopt puts existing code on GCF, and gcf-init sets up `gcf/` and revises the pillars