gcf-aif-ext 0.5.3 → 0.7.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/README.md CHANGED
@@ -50,6 +50,23 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
50
50
 
51
51
  On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
52
52
 
53
+ ### 0.7.0
54
+
55
+ - `priority:` on an item: a rank, lower runs sooner. `ai-factory gcf prioritize <id> <id> …` ranks items in that order in one commit; `item new --priority <n>`. `gcf board --json` lists ranked items first and carries `priority`.
56
+ - `ai-factory gcf question answer <q-id> --text="<answer>"` writes the human's answer into its record of `gcf/open-questions.md` and commits. An answered D1 no longer blocks its units; the next agent on the unit puts the answer into its slot (`answer: q-NNN`) and removes the record. `gcf board --json` questions carry `question`, `proposed`, `blocks` and `answer`.
57
+ - `ai-factory gcf claim <id>` / `release <id>`: an agent session working an item in the main checkout marks it running. The session's own `context`, `plan check` and `verify` on the item keep the mark fresh (so do a harness run on main and its readset-guard hook), a move on the item clears it, and 2 hours without either expire it. The board and the audit read it as `running`, so a harness run does not take the item, and the core refuses any move on it but the session's own (`submit`, `close`, `block` with `--claimed`).
58
+ - `prioritize` leaves the rank of a ready item it does not name (a running one): the named items take the free ranks around it.
59
+ - `run_mode: worktree | main` in `gcf/config.yml` (Methodology §7): a project whose agent works through an editor bound to the project folder sets `main`. `gcf board --json` carries it.
60
+ - `/gcf-card` cuts several items along subsystem boundaries, marks two items of one subsystem that change different files `parallel: ok`, ranks them with `prioritize`, and keeps `depends` for real needs only.
61
+
62
+ ### 0.6.0
63
+
64
+ - `gcf board --json` carries `schema: 1`, the version of its format (Methodology §11 lists every field); a board refuses a version it does not know. New fields: per item `file`, `plan_file`, `log`, `depends`, `milestone`, `tune_count`, `since`, `model`, `effort` and `moves` (what the item takes now by Methodology §4.1, empty while a run drives it); at the top `history` and `graveyard`.
65
+ - `item new --model <m> --effort <e>`: the run of the item uses them (gcf-harness-aif-ext 0.7). One word each.
66
+ - `playtest session --json` and `live check --json` answer JSON: the flag went to the parent command, which printed text.
67
+ - `--commit` on `item new`, `inbox add` and `inbox drop` records the move alone (`gcf(item): <id>`, `gcf(inbox): …`) for a caller with no console, a board. Without it nothing is committed, as before. Inbox lines added earlier without a commit go into the same commit: the inbox is raw input.
68
+ - `inbox add --text=<text>`: a text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of ai-factory's commands, `--` included.
69
+
53
70
  ### 0.5.3
54
71
 
55
72
  - `gcf init` restamps an unplayed item frozen by 0.4.0 (the hash of its body alone), so its `live:` is guarded too — only while that `live:` is still the one its close committed. Commit the restamped items it names.
package/commands/gcf.js CHANGED
@@ -18,6 +18,40 @@ const out = (json, value, human) =>
18
18
 
19
19
  const HARNESS = 'gcf-harness-aif-ext';
20
20
 
21
+ const CLAIMED = 'the agent session that claimed the item (gcf claim) records its own move';
22
+
23
+ /** `--claimed`: this move comes from the session that holds the item's mark. */
24
+ const claimedBy = (o, id) => {
25
+ if (o.claimed) process.env.GCF_RUN_ITEM = id;
26
+ };
27
+
28
+ /** A session's own command on its item keeps the item's running mark fresh. */
29
+ const touchSession = async (id) => (await lib('session-mark')).touchSession(process.cwd(), id);
30
+
31
+ function registerSession(gcf) {
32
+ gcf
33
+ .command('claim <id>')
34
+ .description(
35
+ 'An agent session starts working the item on main: the board shows it running until a ' +
36
+ 'move ends it, release, or 30 minutes without a gcf command on it',
37
+ )
38
+ .action(async (id) => {
39
+ const { buildLedger } = await lib('ledger');
40
+ if (!(await buildLedger(process.cwd())).item(id)) throw new Error(`${id} is not a work item`);
41
+ await (await lib('session-mark')).claimSession(process.cwd(), id);
42
+ console.log(`claimed ${id}`);
43
+ });
44
+ gcf
45
+ .command('release <id>')
46
+ .description('The session stops working the item without a move: it no longer shows running')
47
+ .action(async (id) => {
48
+ const { isValidId } = await lib('id');
49
+ if (!isValidId(id)) throw new Error(`${id} is not an item id`);
50
+ (await lib('session-mark')).releaseSession(process.cwd(), id);
51
+ console.log(`released ${id}`);
52
+ });
53
+ }
54
+
21
55
  /**
22
56
  * `--harness` / `--no-harness` decide. Without either, ask only when someone can answer
23
57
  * and the answer matters: an agent, CI or MinTTY run has no terminal, and a project
@@ -195,6 +229,7 @@ function registerContext(gcf) {
195
229
  .description('Assemble the readset for a work item (GCF-PRD §9.1)')
196
230
  .option('--json', 'machine output')
197
231
  .action(async (itemId, opts) => {
232
+ await touchSession(itemId);
198
233
  const { buildContext } = await lib('context');
199
234
  const ctx = await buildContext(process.cwd(), itemId);
200
235
  // Metric 1 (GCF-PRD §14): readset size over time, one csv line per call.
@@ -321,7 +356,8 @@ function registerPlaytest(gcf) {
321
356
  const r = await (
322
357
  await lib('playtest-list')
323
358
  ).playtestSession(process.cwd(), { build: Boolean(o.build) });
324
- out(o.json, r, sessionText);
359
+ // Commander gives a flag both levels define to the parent: `playtest session --json`.
360
+ out(o.json || playtest.opts().json, r, sessionText);
325
361
  if (r.build && !r.build.ok) process.exitCode = 1;
326
362
  });
327
363
  }
@@ -643,6 +679,10 @@ function registerItem(gcf) {
643
679
  .option('--milestone <name>', 'the milestone of game.md it belongs to')
644
680
  .option('--release <version>', 'a release: the version it ships')
645
681
  .option('--channel <name>', 'a release: where it ships, e.g. steam')
682
+ .option('--priority <rank>', 'its rank: 1, 2, 3… (lower runs sooner)')
683
+ .option('--model <name>', 'the model its run uses, e.g. opus')
684
+ .option('--effort <level>', 'the effort its run uses, e.g. high')
685
+ .option('--commit', 'commit it (gcf(item)): for a caller with no console, e.g. a board')
646
686
  .action(async (id, o) => {
647
687
  const { newItem } = await lib('work-ops');
648
688
  const r = await newItem(process.cwd(), {
@@ -660,18 +700,45 @@ function registerItem(gcf) {
660
700
  milestone: o.milestone,
661
701
  version: o.release,
662
702
  channel: o.channel,
703
+ model: o.model,
704
+ effort: o.effort,
705
+ priority: o.priority === undefined ? undefined : Number(o.priority),
706
+ commit: o.commit === true,
663
707
  });
664
- console.log(r.item);
708
+ console.log(`${r.item}${shortHash(r.hash)}`);
665
709
  });
666
710
  }
667
711
 
668
712
  function registerItemMoves(gcf) {
669
713
  const ops = () => lib('work-ops');
714
+ gcf
715
+ .command('prioritize <ids...>')
716
+ .description('Rank items in this order: priority 1, 2, 3… (lower runs sooner; commit)')
717
+ .option('--no-commit', 'skip the commit (tests only)')
718
+ .action(async (ids, o) => {
719
+ const r = await (await ops()).prioritizeItems(process.cwd(), ids, { commit: o.commit });
720
+ console.log(`ranked ${r.ids.join(', ')}${shortHash(r.hash)}`);
721
+ });
722
+ const question = gcf.command('question').description('Open questions (gcf/open-questions.md)');
723
+ question
724
+ .command('answer <qid>')
725
+ .description('The human answers a question: written into its record, commit gcf(answer)')
726
+ // An answer may start with a hyphen; as `--text=<text>` it stays one value.
727
+ .requiredOption('--text <text>', 'the answer, one line')
728
+ .option('--no-commit', 'skip the commit (tests only)')
729
+ .action(async (qid, o) => {
730
+ const r = await (
731
+ await lib('questions')
732
+ ).answerQuestion(process.cwd(), qid, o.text, { commit: o.commit });
733
+ console.log(`answered ${r.id}${shortHash(r.hash)}`);
734
+ });
670
735
  gcf
671
736
  .command('submit <id>')
672
737
  .description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
738
+ .option('--claimed', CLAIMED)
673
739
  .option('--no-commit', 'skip the commit (tests only)')
674
740
  .action(async (id, o) => {
741
+ claimedBy(o, id);
675
742
  const r = await (await ops()).submitItem(process.cwd(), id, { commit: o.commit });
676
743
  console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
677
744
  });
@@ -684,8 +751,10 @@ function registerItemMoves(gcf) {
684
751
  (v, p) => [...p, v],
685
752
  [],
686
753
  )
754
+ .option('--claimed', CLAIMED)
687
755
  .option('--no-commit', 'skip the commit (tests only)')
688
756
  .action(async (id, o) => {
757
+ claimedBy(o, id);
689
758
  const r = await (
690
759
  await ops()
691
760
  ).closeItem(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
@@ -716,8 +785,10 @@ function registerDecide(gcf) {
716
785
  .command('block <id>')
717
786
  .description('ready → blocked, with the reason or the question (commit gcf(block))')
718
787
  .requiredOption('--reason <text>', 'why it cannot go on, or the question for you')
788
+ .option('--claimed', CLAIMED)
719
789
  .option('--no-commit', 'skip the commit (tests only)')
720
790
  .action(async (id, o) => {
791
+ claimedBy(o, id);
721
792
  const r = await (
722
793
  await ops()
723
794
  ).blockItem(process.cwd(), id, { reason: o.reason, commit: o.commit });
@@ -736,6 +807,36 @@ function registerDecide(gcf) {
736
807
  });
737
808
  }
738
809
 
810
+ /**
811
+ * An inbox edit; with `commit`, committed alone under the main lock — a board has no console to
812
+ * commit from. Returns the commit hash, or null.
813
+ * @param {boolean | undefined} commit
814
+ * @param {string} message
815
+ * @param {(root: string) => Promise<unknown>} edit
816
+ */
817
+ async function inboxMove(commit, message, edit) {
818
+ const { gcfRoot } = await lib('gcf-paths');
819
+ const root = gcfRoot();
820
+ if (!commit) {
821
+ await edit(root);
822
+
823
+ return null;
824
+ }
825
+
826
+ const { commitMove } = await lib('unit-ops');
827
+ const inbox = path.join(root, 'inbox.md');
828
+
829
+ // Raw input (merge=union): lines added before without a commit ride along with this one.
830
+ return commitMove(process.cwd(), message, {
831
+ files: [inbox],
832
+ inputs: [inbox],
833
+ mutate: async () => {
834
+ await edit(root);
835
+ },
836
+ commit: true,
837
+ });
838
+ }
839
+
739
840
  function registerInbox(gcf) {
740
841
  const inbox = gcf
741
842
  .command('inbox')
@@ -749,21 +850,34 @@ function registerInbox(gcf) {
749
850
  );
750
851
  });
751
852
  inbox
752
- .command('add <text>')
853
+ .command('add [text]')
753
854
  .description('Append an entry')
754
855
  .option('--source <source>', 'playtest | ci | agent | operator', 'operator')
755
- .action(async (text, o) => {
756
- const { gcfRoot } = await lib('gcf-paths');
757
- const e = await (await lib('inbox')).addInbox(gcfRoot(), text, o.source);
758
- console.log(e.id);
856
+ // A text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of
857
+ // ai-factory's commands, `--` included: as `--text=<text>` it stays one value.
858
+ .option('--text <text>', 'the entry, when it starts with a hyphen')
859
+ .option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
860
+ .action(async (arg, o) => {
861
+ const text = o.text ?? arg;
862
+ if (!text || (o.text !== undefined && arg !== undefined))
863
+ throw new Error('give the entry once: as the argument, or as --text=<text>');
864
+ const { addInbox } = await lib('inbox');
865
+ let id = '';
866
+ const hash = await inboxMove(o.commit, 'gcf(inbox): add', async (root) => {
867
+ id = (await addInbox(root, text, o.source)).id;
868
+ });
869
+ console.log(`${id}${shortHash(hash)}`);
759
870
  });
760
871
  inbox
761
872
  .command('drop <in-id>')
762
873
  .description('Erase an entry: triaged as noise, a duplicate, or turned into an item')
763
- .action(async (id) => {
764
- const { gcfRoot } = await lib('gcf-paths');
765
- await (await lib('inbox')).dropInbox(gcfRoot(), id);
766
- console.log(`erased ${id}`);
874
+ .option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
875
+ .action(async (id, o) => {
876
+ const { dropInbox } = await lib('inbox');
877
+ const hash = await inboxMove(o.commit, `gcf(inbox): drop ${id}`, (root) =>
878
+ dropInbox(root, id),
879
+ );
880
+ console.log(`erased ${id}${shortHash(hash)}`);
767
881
  });
768
882
  }
769
883
 
@@ -794,7 +908,7 @@ function registerLive(gcf) {
794
908
  .option('--json', 'machine output')
795
909
  .action(async (o) => {
796
910
  const r = await (await lib('live')).checkLive(process.cwd());
797
- out(o.json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
911
+ out(o.json || live.opts().json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
798
912
  if (r.issues.length) process.exitCode = 1;
799
913
  });
800
914
  }
@@ -807,6 +921,7 @@ function registerPlan(gcf) {
807
921
  .description('Deterministic plan check: it exists, has task checkboxes, readset in budget')
808
922
  .option('--json', 'machine output')
809
923
  .action(async (id, o) => {
924
+ await touchSession(id);
810
925
  const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
811
926
  out(o.json, r, (x) =>
812
927
  x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
@@ -822,6 +937,7 @@ function registerVerify(gcf) {
822
937
  .option('--mark', 'in its worktree: green and committed → commit the GCF-Verified trailer')
823
938
  .option('--json', 'machine output')
824
939
  .action(async (id, o) => {
940
+ await touchSession(id);
825
941
  const r = o.mark
826
942
  ? await (await lib('integrate')).markVerified(process.cwd(), id)
827
943
  : await (await lib('verify')).verifyItem(process.cwd(), id);
@@ -1009,6 +1125,7 @@ export function register(program) {
1009
1125
  registerMoves(gcf);
1010
1126
  registerItem(gcf);
1011
1127
  registerItemMoves(gcf);
1128
+ registerSession(gcf);
1012
1129
  registerDecide(gcf);
1013
1130
  registerInbox(gcf);
1014
1131
  registerLive(gcf);
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.5.3",
4
+ "version": "0.7.0",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
package/lib/audit-core.js CHANGED
@@ -10,6 +10,7 @@ import { buildLedger } from './ledger.js';
10
10
  import { STORED } from './transitions.js';
11
11
  import { openDepends } from './work-ops.js';
12
12
  import { runHolder } from './main-lock.js';
13
+ import { sessionActive } from './session-mark.js';
13
14
 
14
15
  function git(args, cwd) {
15
16
  // A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
@@ -42,7 +43,7 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
43
  * @property {number} [schema]
43
44
  * @property {Array<{id: string, type: string, gate: string, status: string, system: string[],
44
45
  * depends: string[], parent: string | null, tune_count: number, parallel: string,
45
- * running: boolean}>} [items]
46
+ * priority: number | null, running: boolean}>} [items]
46
47
  * @property {{blocking: number, units: number, ready: number}} [design]
47
48
  */
48
49
 
@@ -96,7 +97,10 @@ export async function audit(projectDir) {
96
97
  parent: c.data.parent ?? null,
97
98
  tune_count: c.data.tune_count ?? 0,
98
99
  parallel: c.data.parallel ?? 'serial',
99
- running: runHolder(projectDir, c.data.id) !== null,
100
+ priority: c.data.priority ?? null,
101
+ // A harness run's live lock, or an interactive session's fresh mark on main.
102
+ running:
103
+ runHolder(projectDir, c.data.id) !== null || sessionActive(projectDir, String(c.data.id)),
100
104
  }));
101
105
  const design = await designCheck(projectDir);
102
106
  checkItems(report, items, config, { projectDir, ledger });
package/lib/board.js CHANGED
@@ -12,29 +12,82 @@ import { verifiedBranches, worktreeOf } from './integrate.js';
12
12
  import { resolveInside } from './safe-path.js';
13
13
  import { wipOf } from './audit-core.js';
14
14
  import { runHolder } from './main-lock.js';
15
+ import { ITEM_TYPES } from './validate-item.js';
16
+ import { movesOf } from './transitions.js';
17
+ import { listQuestions } from './questions.js';
18
+ import { sessionActive } from './session-mark.js';
19
+
20
+ // The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
21
+ // when a field changes meaning or goes away; a new field does not bump it.
22
+ export const BOARD_SCHEMA = 1;
15
23
 
16
24
  const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
17
25
 
18
26
  /**
19
- * Plan progress `{tasks, done}` from its checkboxes, null without a plan file (§3.3); a plan:
20
- * outside the project or not a file reads as `{error}`, the rest of the board still shows.
27
+ * Plan progress `{tasks, done}` from its checkboxes and the plan's project path, both null
28
+ * without a plan file (§3.3); a plan: outside the project or not a file reads as `{error}`, the
29
+ * rest of the board still shows.
21
30
  */
22
31
  async function progress(projectDir, item) {
32
+ const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
23
33
  try {
24
- const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
25
34
  const file = resolveInside(projectDir, plan);
26
- if (!existsSync(file)) return null;
35
+ if (!existsSync(file)) return { plan: null, plan_file: null };
27
36
  const text = await readFile(file, 'utf8');
28
37
 
29
38
  return {
30
- tasks: (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length,
31
- done: (text.match(/^\s*- \[[xX]\] /gm) ?? []).length,
39
+ plan: {
40
+ tasks: (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length,
41
+ done: (text.match(/^\s*- \[[xX]\] /gm) ?? []).length,
42
+ },
43
+ plan_file: plan,
32
44
  };
33
45
  } catch (e) {
34
- return { error: e.message };
46
+ return { plan: { error: e.message }, plan_file: null };
35
47
  }
36
48
  }
37
49
 
50
+ /** The lines `- <a> | <b> | …` of a gcf record file, split on the pipes. */
51
+ async function records(root, name) {
52
+ const file = path.join(root, name);
53
+ if (!existsSync(file)) return [];
54
+
55
+ return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
56
+ const m = l.match(/^- (\S+)\s*\|(.*)$/);
57
+
58
+ return m ? [[m[1], ...m[2].split('|').map((f) => f.trim())]] : [];
59
+ });
60
+ }
61
+
62
+ /** history.md: `- <id> | <event> | <date>` (§9). */
63
+ const historyOf = async (root) =>
64
+ (await records(root, 'history.md')).map(([id, event, date]) => ({
65
+ id,
66
+ event: event || null,
67
+ date: date || null,
68
+ }));
69
+
70
+ /**
71
+ * graveyard.md: `- <id> | <type> | <reason> | tags: <tags>`; a 0.3 line has no type, and its
72
+ * second field is a type only when it names one (§9).
73
+ */
74
+ const graveyardOf = async (root) =>
75
+ (await records(root, 'graveyard.md')).map(([id, ...fields]) => {
76
+ const tags = fields.at(-1)?.startsWith('tags:') ? fields.pop() : null;
77
+ const typed = fields.length > 1 && ITEM_TYPES.includes(fields[0]);
78
+
79
+ return {
80
+ id,
81
+ type: typed ? fields[0] : null,
82
+ reason: (typed ? fields.slice(1) : fields).join(' | ') || null,
83
+ tags: (tags ?? '')
84
+ .slice('tags:'.length)
85
+ .split(',')
86
+ .map((t) => t.trim())
87
+ .filter(Boolean),
88
+ };
89
+ });
90
+
38
91
  /** Verified branches; none outside a repository. */
39
92
  function verifiedOf(projectDir) {
40
93
  try {
@@ -44,21 +97,6 @@ function verifiedOf(projectDir) {
44
97
  }
45
98
  }
46
99
 
47
- /** Open question records of open-questions.md: id and class. */
48
- async function questions(root) {
49
- const file = path.join(root, 'open-questions.md');
50
- if (!existsSync(file)) return [];
51
-
52
- return (await readFile(file, 'utf8'))
53
- .replace(/\r\n/g, '\n')
54
- .split(/\n(?=- id:)/)
55
- .flatMap((rec) => {
56
- const id = rec.match(/^- id:\s*(\S+)/)?.[1];
57
-
58
- return id ? [{ id, class: rec.match(/^\s+class:\s*(\S+)/m)?.[1] ?? null }] : [];
59
- });
60
- }
61
-
62
100
  /** The phase status, or the reason it cannot be read: the board still shows the rest. */
63
101
  async function phaseOf(projectDir) {
64
102
  try {
@@ -68,48 +106,92 @@ async function phaseOf(projectDir) {
68
106
  }
69
107
  }
70
108
 
109
+ /** The release block with every field: one a malformed block lacks is null (live check says so). */
110
+ const releaseOf = (r) =>
111
+ r && {
112
+ version: r.version,
113
+ ...Object.fromEntries(['channel', 'date', 'phase'].map((k) => [k, r[k] ?? null])),
114
+ };
115
+
116
+ /** The run log of an item, project-relative, or null before its first run. */
117
+ function logOf(projectDir, id) {
118
+ const log = `.gcf-session/runs/${id}.log`;
119
+
120
+ return existsSync(path.join(projectDir, log)) ? log : null;
121
+ }
122
+
123
+ // Fields of the item file a board shows as they are: null or [] when the file has none.
124
+ const SCALARS = [
125
+ 'title',
126
+ 'parent',
127
+ 'milestone',
128
+ 'priority',
129
+ 'tune_count',
130
+ 'since',
131
+ 'model',
132
+ 'effort',
133
+ ];
134
+
135
+ // Ranked items first, lower rank sooner; the unranked keep their file order after them.
136
+ const byPriority = (a, b) => (a.priority ?? Infinity) - (b.priority ?? Infinity);
137
+ const LISTS = ['children', 'system', 'depends'];
138
+
139
+ /** One item of the board. */
140
+ async function itemOf(projectDir, c, verified) {
141
+ const id = String(c.data.id);
142
+ // A trailer counts only while the item waits on main for its integrate.
143
+ const ready = c.data.status === 'ready' && verified.has(id);
144
+ // `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
145
+ // item, is a run; a trailer is a passed DoD.
146
+ const running =
147
+ runHolder(projectDir, id) !== null ||
148
+ sessionActive(projectDir, id) ||
149
+ (c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id)));
150
+
151
+ return {
152
+ id,
153
+ type: c.data.type,
154
+ gate: c.data.gate,
155
+ status: c.data.status,
156
+ ...Object.fromEntries(SCALARS.map((k) => [k, c.data[k] ?? null])),
157
+ ...Object.fromEntries(LISTS.map((k) => [k, c.data[k] ?? []])),
158
+ ...(c.data.severity ? { severity: c.data.severity } : {}),
159
+ ...(c.data.blocked ? { blocked: c.data.blocked } : {}),
160
+ running,
161
+ verified: ready,
162
+ // What a board may offer (§4.1): nothing while a run drives the item.
163
+ moves: running ? [] : movesOf(c.data),
164
+ file: `gcf/work/${c.name}`,
165
+ log: logOf(projectDir, id),
166
+ ...(await progress(projectDir, c)),
167
+ };
168
+ }
169
+
71
170
  export async function board(projectDir) {
72
171
  const root = path.join(path.resolve(projectDir), 'gcf');
73
172
  const ledger = await buildLedger(projectDir);
74
173
  const verified = verifiedOf(projectDir);
75
174
  const items = [];
76
- for (const c of ledger.items) {
77
- const id = String(c.data.id);
78
- // A trailer counts only while the item waits on main for its integrate.
79
- const ready = c.data.status === 'ready' && verified.has(id);
80
- items.push({
81
- id,
82
- type: c.data.type,
83
- gate: c.data.gate,
84
- status: c.data.status,
85
- title: c.data.title ?? null,
86
- parent: c.data.parent ?? null,
87
- children: c.data.children ?? [],
88
- system: c.data.system ?? [],
89
- ...(c.data.severity ? { severity: c.data.severity } : {}),
90
- ...(c.data.blocked ? { blocked: c.data.blocked } : {}),
91
- // `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
92
- // item, is a run; a trailer is a passed DoD.
93
- running:
94
- runHolder(projectDir, id) !== null ||
95
- (c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id))),
96
- verified: ready,
97
- plan: await progress(projectDir, c),
98
- });
99
- }
175
+ for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
176
+ items.sort(byPriority);
100
177
 
101
178
  const config = await readConfig(root);
102
179
  const live = await readLive(root);
103
180
 
104
181
  return {
182
+ schema: BOARD_SCHEMA,
183
+ run_mode: config.run_mode,
105
184
  phase: await phaseOf(projectDir),
106
- release: live.release,
185
+ // A malformed block reads with fields missing (live check names it): null, not absent.
186
+ release: releaseOf(live.release),
107
187
  columns: Object.fromEntries(
108
188
  COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
109
189
  ),
110
190
  items,
111
191
  wip: wipOf(ledger.items, config),
112
192
  inbox: await listInbox(root),
113
- questions: await questions(root),
193
+ questions: await listQuestions(root),
194
+ history: await historyOf(root),
195
+ graveyard: await graveyardOf(root),
114
196
  };
115
197
  }
@@ -121,7 +121,13 @@ function itemsIn(text, key) {
121
121
  export const recordsIn = (text, key) =>
122
122
  readRecords(itemsIn(text, key)).records.map((r) => r.values);
123
123
 
124
- /** open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. */
124
+ /** A D1 record nobody answered yet: only such a question holds its units. */
125
+ const blocksUnits = (rec) => /^\s+class:\s*D1\b/m.test(rec) && !/^\s+answer:/m.test(rec);
126
+
127
+ /**
128
+ * open-questions.md: every record id, every `blocks:` reference, the D1 ones per blocked id. A
129
+ * D1 the human answered (`answer:`) blocks no more: the next agent on the unit applies it.
130
+ */
125
131
  export function questionBlocks(text) {
126
132
  /** @type {Map<string, string[]>} */
127
133
  const d1 = new Map();
@@ -133,7 +139,7 @@ export function questionBlocks(text) {
133
139
  const ids = listOf(rec.match(/^\s+blocks:\s*(.+)$/m)?.[1] ?? '');
134
140
  if (!qid || ids === 'TBD' || !ids.length) continue;
135
141
  refs.push({ qid, ids });
136
- if (!/^\s+class:\s*D1\b/m.test(rec)) continue;
142
+ if (!blocksUnits(rec)) continue;
137
143
  for (const id of ids) d1.set(id, [...(d1.get(id) ?? []), qid]);
138
144
  }
139
145
 
package/lib/gcf-paths.js CHANGED
@@ -26,7 +26,27 @@ export function gcfRoot(projectDir = process.cwd()) {
26
26
  return root;
27
27
  }
28
28
 
29
+ // How a harness run drives an item (Methodology §7): `worktree` — each in its own copy, several
30
+ // at once; `main` — one at a time in the project's own folder, for an agent that works through
31
+ // a running editor bound to that folder. The project chooses; the framework knows no engine.
32
+ export const RUN_MODES = ['worktree', 'main'];
33
+
34
+ function runModeOf(v = 'worktree') {
35
+ if (!RUN_MODES.includes(v))
36
+ throw new Error(`gcf/config.yml: run_mode must be ${RUN_MODES.join(' | ')}, got ${v}`);
37
+
38
+ return v;
39
+ }
40
+
41
+ /** @returns {Promise<Record<string, any>>} */
29
42
  export async function readConfig(root) {
43
+ const config = await readNumbers(root);
44
+
45
+ return { ...config, run_mode: runModeOf(config.run_mode) };
46
+ }
47
+
48
+ /** @returns {Promise<Record<string, any>>} */
49
+ async function readNumbers(root) {
30
50
  const file = path.join(root, 'config.yml');
31
51
  if (!existsSync(file)) return { ...DEFAULT_CONFIG };
32
52
  const parsed = YAML.parse(await readFile(file, 'utf8')) ?? {};
package/lib/integrate.js CHANGED
@@ -27,7 +27,7 @@ function git(cwd, args, { check = true } = {}) {
27
27
  }
28
28
 
29
29
  /** `.gcf-session/` stays out of `git status` without a tracked .gitignore change. */
30
- async function excludeSession(projectDir) {
30
+ export async function excludeSession(projectDir) {
31
31
  const common = path.resolve(projectDir, git(projectDir, ['rev-parse', '--git-common-dir']).out);
32
32
  const file = path.join(common, 'info', 'exclude');
33
33
  const text = existsSync(file) ? await readFile(file, 'utf8') : '';
@@ -238,7 +238,7 @@ async function integrateMerged(projectDir, id, item) {
238
238
  if (!item || item.data.status !== 'ready' || movedSinceMerge(projectDir, id))
239
239
  return { id, skipped: `${branchOf(id)} is in main already`, ...cleanUp(projectDir, id) };
240
240
 
241
- return { id, ...(await recordMove(projectDir, item)), ...cleanUp(projectDir, id) };
241
+ return { ...(await recordMove(projectDir, item)), id, ...cleanUp(projectDir, id) };
242
242
  }
243
243
 
244
244
  async function integrateOne(projectDir, id) {
@@ -250,7 +250,7 @@ async function integrateOne(projectDir, id) {
250
250
  if (git(projectDir, ancestor, { check: false }).ok) return integrateMerged(projectDir, id, item);
251
251
  if (!item || item.data.status !== 'ready')
252
252
  return { id, skipped: `${id} is ${item ? item.data.status : 'not a work item'} on main` };
253
- if (item.data.type === 'spike') return { id, ...(await integrateSpike(projectDir, item)) };
253
+ if (item.data.type === 'spike') return { ...(await integrateSpike(projectDir, item)), id };
254
254
  const foreign = foreignGcfEdits(projectDir, id);
255
255
  if (foreign.length)
256
256
  return {
@@ -263,7 +263,7 @@ async function integrateOne(projectDir, id) {
263
263
  if (failed) return { id, ...failed };
264
264
  const moved = await recordMove(projectDir, (await buildLedger(projectDir)).item(id));
265
265
 
266
- return { id, ...moved, ...cleanUp(projectDir, id) };
266
+ return { ...moved, id, ...cleanUp(projectDir, id) };
267
267
  }
268
268
 
269
269
  /**
package/lib/live.js CHANGED
@@ -66,7 +66,7 @@ export function parseLive(text) {
66
66
  /** A record line of capabilities or known-issues; false when the line is not one. */
67
67
  const recordLine = (line) => {
68
68
  const start = line.match(/^- id:\s*(\S+)\s*$/);
69
- if (start && section) {
69
+ if (start && section && section !== 'release') {
70
70
  rec = { id: start[1], summary: '' };
71
71
  out[section].push(rec);
72
72
 
@@ -106,7 +106,11 @@ const recordText = (r) =>
106
106
 
107
107
  const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
108
108
 
109
- /** LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly. */
109
+ /**
110
+ * LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly.
111
+ * @param {{phase?: string | null, release?: LiveRelease | null, capabilities: LiveRecord[],
112
+ * knownIssues: LiveRecord[]}} live
113
+ */
110
114
  export function serializeLive({ phase = null, release = null, capabilities, knownIssues }) {
111
115
  const block = (key, list) =>
112
116
  `${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
@@ -73,7 +73,7 @@ export async function playtestSession(
73
73
  commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
74
74
  // No commit (no repository, or none yet): the build is unknown, not clean.
75
75
  dirty: dirty === null ? null : Boolean(dirty),
76
- build: null,
76
+ build: /** @type {ReturnType<typeof runDod> | null} */ (null),
77
77
  items,
78
78
  };
79
79
  if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
@@ -0,0 +1,95 @@
1
+ // gcf/open-questions.md (GCF-PRD §11.8): the questions only the human answers (D1) and the
2
+ // agent's own assumptions (D2). An answer is written into the record itself: an answered D1
3
+ // blocks no unit, and the next agent on the unit puts the answer into its slot (provenance
4
+ // `answer: q-NNN`) and removes the record.
5
+ import { readFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import { atomicWrite } from './safe-path.js';
9
+ import { commitMove } from './unit-ops.js';
10
+
11
+ const FIELDS = ['class', 'question', 'proposed', 'blocks', 'answer'];
12
+
13
+ const fileOf = (root) => path.join(root, 'open-questions.md');
14
+
15
+ /**
16
+ * The records of a file's text, each `{id, text, start}`: a `- id:` line and the indented lines
17
+ * under it. A blank or unindented line ends the record: a heading after the last record is not
18
+ * part of it.
19
+ */
20
+ function recordsOf(text) {
21
+ const records = [];
22
+ let at = 0;
23
+ /** @type {{id: string, text: string, start: number} | null} */
24
+ let rec = null;
25
+ for (const line of text.split(/(?<=\n)/)) {
26
+ const id = line.match(/^- id:\s*(\S+)/)?.[1];
27
+ if (id) records.push((rec = { id, text: line, start: at }));
28
+ else if (rec && /^[ \t]+\S/.test(line)) rec.text += line;
29
+ else rec = null;
30
+ at += line.length;
31
+ }
32
+
33
+ return records;
34
+ }
35
+
36
+ /**
37
+ * Every question: id, class, the question, the proposed answer, the ids it blocks and the
38
+ * human's answer, each null when the record has none.
39
+ */
40
+ export async function listQuestions(root) {
41
+ const file = fileOf(root);
42
+ if (!existsSync(file)) return [];
43
+
44
+ return recordsOf(await readFile(file, 'utf8')).map(({ id, text }) => ({
45
+ id,
46
+ ...Object.fromEntries(
47
+ FIELDS.map((k) => [
48
+ k,
49
+ text.match(new RegExp(`^[ \\t]+${k}:[ \\t]*(.*?)\\s*$`, 'm'))?.[1] || null,
50
+ ]),
51
+ ),
52
+ }));
53
+ }
54
+
55
+ /** The record text with its `answer:` set: an old answer is replaced, a new one appended. */
56
+ function withAnswer(record, answer) {
57
+ const eol = record.includes('\r\n') ? '\r\n' : '\n';
58
+ const indent = record.match(/^([ \t]+)\w+:/m)?.[1] || ' ';
59
+ const line = `${indent}answer: ${answer}`;
60
+ // A function, not a string: `$&` in the operator's answer is text, not a pattern.
61
+ if (/^[ \t]+answer:/m.test(record))
62
+ return record.replace(/^[ \t]+answer:.*?(?=\r?$)/m, () => line);
63
+ const body = record.replace(/(\r?\n)+$/, '');
64
+
65
+ return `${body}${eol}${line}${record.slice(body.length) || eol}`;
66
+ }
67
+
68
+ /**
69
+ * The human answers question `qid` (`gcf question answer`): the answer goes into its record, one
70
+ * line, and commits as `gcf(answer): <qid>`. Refuses an id the file does not hold.
71
+ */
72
+ export async function answerQuestion(projectDir, qid, answer, { commit = true } = {}) {
73
+ const text = String(answer ?? '')
74
+ .replace(/\s+/g, ' ')
75
+ .trim();
76
+ if (!text) throw new Error('an answer needs text');
77
+ const file = fileOf(path.join(path.resolve(projectDir), 'gcf'));
78
+ const old = existsSync(file) ? await readFile(file, 'utf8') : '';
79
+ const rec = recordsOf(old).find((r) => r.id === qid);
80
+ if (!rec) throw new Error(`${qid} is not a record of gcf/open-questions.md`);
81
+
82
+ const hash = await commitMove(projectDir, `gcf(answer): ${qid}`, {
83
+ files: [file],
84
+ mutate: () =>
85
+ atomicWrite(
86
+ file,
87
+ old.slice(0, rec.start) +
88
+ withAnswer(rec.text, text) +
89
+ old.slice(rec.start + rec.text.length),
90
+ ),
91
+ commit,
92
+ });
93
+
94
+ return { id: qid, answer: text, hash };
95
+ }
@@ -0,0 +1,52 @@
1
+ // An agent session working an item in the main checkout (Methodology §4: `running`). A harness
2
+ // run holds a lock with its pid; an interactive session has no process the core can see, so
3
+ // `/gcf-run` claims the item with a mark, the session's own gcf commands on the item keep it
4
+ // fresh (and so do a harness run on main and the harness's readset-guard hook on each read),
5
+ // and the move that ends the run clears it. A mark nobody refreshed for SESSION_TTL_MS is a
6
+ // session that stopped: the item reads as not running again.
7
+ import { existsSync, rmSync, statSync, utimesSync } from 'node:fs';
8
+ import { mkdir, writeFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { excludeSession } from './integrate.js';
11
+
12
+ // A run's default timeout: an agent can implement for long without a gcf command. The harness
13
+ // and its readset-guard hook refresh a live session's mark meanwhile.
14
+ export const SESSION_TTL_MS = 120 * 60_000;
15
+
16
+ const markOf = (projectDir, id) =>
17
+ path.join(path.resolve(projectDir), '.gcf-session', 'runs', `${id}.session`);
18
+
19
+ /** Mark item `id` as worked by this session. */
20
+ export async function claimSession(projectDir, id) {
21
+ const file = markOf(projectDir, id);
22
+ await excludeSession(projectDir);
23
+ await mkdir(path.dirname(file), { recursive: true });
24
+ await writeFile(file, `${new Date().toISOString()}\n`);
25
+ }
26
+
27
+ /** Keep a session's mark fresh: its own commands on the item call this; no mark, no-op. */
28
+ export function touchSession(projectDir, id) {
29
+ const file = markOf(projectDir, id);
30
+ try {
31
+ const now = new Date();
32
+ utimesSync(file, now, now);
33
+ } catch {
34
+ // No mark: no session claimed the item.
35
+ }
36
+ }
37
+
38
+ /** Clear the mark: the session ended its run, or gave up. */
39
+ export function releaseSession(projectDir, id) {
40
+ rmSync(markOf(projectDir, id), { force: true });
41
+ }
42
+
43
+ /** Whether a session works the item now: a mark refreshed within SESSION_TTL_MS. */
44
+ export function sessionActive(projectDir, id) {
45
+ const file = markOf(projectDir, id);
46
+ if (!existsSync(file)) return false;
47
+ try {
48
+ return Date.now() - statSync(file).mtimeMs < SESSION_TTL_MS;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
@@ -37,6 +37,31 @@ export function allowedFrom(move, status) {
37
37
  /** Statuses `move` starts from, for error messages. */
38
38
  export const fromOf = (move) => FROM[move] ?? [];
39
39
 
40
+ export const FEATURE_ONLY = ['close', 'continue', 'kill'];
41
+ const BUG_ONLY = ['wontfix', 'known'];
42
+
43
+ /** Whether a decision fits an item's type: a feature has its own, wontfix and known a bug's. */
44
+ export const decisionFits = (choice, type) =>
45
+ FEATURE_ONLY.includes(choice) === (type === 'feature') &&
46
+ (!BUG_ONLY.includes(choice) || type === 'bug');
47
+
48
+ /** Whether a move fits the item beyond its status: its gate for submit and close, its type. */
49
+ function fits(move, { type, gate }) {
50
+ const [cmd, arg] = move.split(':');
51
+ if (cmd === 'submit') return gate !== 'auto';
52
+ if (cmd === 'close') return gate === 'auto';
53
+
54
+ return cmd !== 'decide' || decisionFits(arg, type);
55
+ }
56
+
57
+ /**
58
+ * The moves an item may take now (§4.1), named as `allowedFrom` names them: `verdict:keep`,
59
+ * `decide:go`, `unblock`… A board offers these and nothing else; each command still checks the
60
+ * rest itself (dependencies, a live: delta, the tune limit).
61
+ */
62
+ export const movesOf = (data) =>
63
+ Object.keys(FROM).filter((m) => allowedFrom(m, data.status) && fits(m, data));
64
+
40
65
  /** A status an item FILE may carry. */
41
66
  export function isStorable(status) {
42
67
  return STORED.includes(status);
@@ -56,6 +56,7 @@ const FIELDS = [
56
56
  'reopens',
57
57
  'milestone',
58
58
  'parallel',
59
+ 'priority',
59
60
  'plan',
60
61
  'tune_count',
61
62
  'severity',
@@ -146,6 +147,9 @@ const RULES = {
146
147
  reopens: (v) => nullable(isSlug)(v) || 'reopens must name the id it reworks, or null',
147
148
  milestone: (v) => nullable(isSlug)(v) || 'milestone must be a milestone name or null',
148
149
  parallel: (v) => ['ok', 'serial'].includes(v) || 'parallel must be ok or serial',
150
+ priority: (v) =>
151
+ nullable((x) => Number.isInteger(x) && x >= 1)(v) ||
152
+ 'priority must be a rank, an integer >= 1 (lower runs sooner), or null',
149
153
  plan: (v) => nullable(isText)(v) || 'plan must be a path or null',
150
154
  tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
151
155
  severity: (v) =>
package/lib/work-ops.js CHANGED
@@ -11,9 +11,8 @@ import path from 'node:path';
11
11
  import { parse, serialize } from './frontmatter.js';
12
12
  import { buildLedger } from './ledger.js';
13
13
  import { readConfig } from './gcf-paths.js';
14
- import { atomicWrite, withFileRollback } from './safe-path.js';
15
- import { writeIndex } from './index-gen.js';
16
- import { allowedFrom, fromOf } from './transitions.js';
14
+ import { atomicWrite } from './safe-path.js';
15
+ import { allowedFrom, decisionFits, FEATURE_ONLY, fromOf } from './transitions.js';
17
16
  import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
18
17
  import { isValidId } from './id.js';
19
18
  import { commitMove, addConstraints } from './unit-ops.js';
@@ -39,6 +38,7 @@ import {
39
38
  import { dropInbox } from './inbox.js';
40
39
  import { refuseByPhase } from './phase.js';
41
40
  import { runHolder } from './main-lock.js';
41
+ import { releaseSession, sessionActive } from './session-mark.js';
42
42
 
43
43
  /**
44
44
  * Options of a committed move; each move reads the ones it needs.
@@ -174,6 +174,13 @@ async function begin(projectDir, id, move) {
174
174
  `${id} is driven by a harness run (pid ${run}): wait until that run ends; if no harness ` +
175
175
  `run is going, delete .gcf-session/runs/${id}.lock`,
176
176
  );
177
+ // The session that claimed the item moves it itself (GCF_RUN_ITEM, set by `--claimed` or by
178
+ // a harness run on main); anyone else waits, as for a run lock.
179
+ if (sessionActive(projectDir, id) && process.env.GCF_RUN_ITEM !== id)
180
+ throw new Error(
181
+ `${id} is worked by an agent session (gcf claim): wait until it moves the item, or run ` +
182
+ `ai-factory gcf release ${id} when no session works it`,
183
+ );
177
184
  const files = [item.file];
178
185
  const steps = [];
179
186
  const liveOps = [];
@@ -203,13 +210,18 @@ async function begin(projectDir, id, move) {
203
210
  }
204
211
 
205
212
  /** Commit the move as `gcf(<move>): <tail>` with every file it touched (item edits included). */
206
- const finish = (m, move, tail, commit) =>
207
- commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
213
+ async function finish(m, move, tail, commit) {
214
+ const hash = await commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
208
215
  files: m.files,
209
216
  mutate: m.run,
210
217
  commit,
211
218
  inputs: [m.item.file],
212
219
  });
220
+ // A move on the item ends the session that worked it: the board stops showing it running.
221
+ releaseSession(m.projectDir, String(m.item.data.id));
222
+
223
+ return hash;
224
+ }
213
225
 
214
226
  const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
215
227
 
@@ -646,10 +658,9 @@ const DECIDE = {
646
658
  },
647
659
  kill: killFeature,
648
660
  };
649
- const FEATURE_ONLY = ['close', 'continue', 'kill'];
650
-
651
661
  /** Refuse a decision that does not fit the item's type. */
652
662
  function fitDecision(choice, item) {
663
+ if (decisionFits(choice, item.data.type)) return;
653
664
  const feature = item.data.type === 'feature';
654
665
  if (FEATURE_ONLY.includes(choice) !== feature)
655
666
  throw new Error(
@@ -657,8 +668,7 @@ function fitDecision(choice, item) {
657
668
  ? `${item.data.id} is a feature: decide close | continue | kill`
658
669
  : `decide ${choice} is for a feature; ${item.data.id} is a ${item.data.type}`,
659
670
  );
660
- if (['wontfix', 'known'].includes(choice) && item.data.type !== 'bug')
661
- throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
671
+ throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
662
672
  }
663
673
 
664
674
  /** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
@@ -727,6 +737,9 @@ export async function dropItem(projectDir, id, opts = {}) {
727
737
  return { id, hash: await finish(m, 'drop', id, commit), removal };
728
738
  }
729
739
 
740
+ // A leading hyphen would read as a flag of the agent's command line.
741
+ export const RUN_WORD = /^[A-Za-z0-9][\w.-]*$/;
742
+
730
743
  /** The version and channel of a new item: well-formed, and only on a release. */
731
744
  function checkRelease(spec) {
732
745
  const { type, version, channel } = spec;
@@ -740,6 +753,28 @@ function checkRelease(spec) {
740
753
  throw new Error('--release and --channel belong to a release item');
741
754
  }
742
755
 
756
+ const RUN_KEYS = ['model', 'effort'];
757
+
758
+ /**
759
+ * How a new item runs: its model and effort (the run passes them to the agent's command line)
760
+ * and its rank among the items.
761
+ */
762
+ function runFields(spec) {
763
+ for (const k of RUN_KEYS)
764
+ if (spec[k] !== undefined && !RUN_WORD.test(spec[k]))
765
+ throw new Error(
766
+ `--${k} is one word that starts with a letter or a digit ` +
767
+ '(then letters, digits, dots, hyphens, underscores), e.g. opus',
768
+ );
769
+ const { priority } = spec;
770
+ if (priority !== undefined && !(Number.isInteger(priority) && priority >= 1))
771
+ throw new Error('--priority is a rank: an integer from 1 (lower runs sooner)');
772
+
773
+ return Object.fromEntries(
774
+ [...RUN_KEYS, 'priority'].filter((k) => spec[k] !== undefined).map((k) => [k, spec[k]]),
775
+ );
776
+ }
777
+
743
778
  /** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
744
779
  function checkNew(ledger, spec) {
745
780
  const { id, type, gate, severity } = spec;
@@ -790,6 +825,7 @@ function optionalFields(spec, gate, since) {
790
825
  ...(spec.milestone ? { milestone: spec.milestone } : {}),
791
826
  ...(spec.version ? { version: spec.version } : {}),
792
827
  ...(spec.channel ? { channel: spec.channel } : {}),
828
+ ...runFields(spec),
793
829
  ...(live ? { live } : {}),
794
830
  };
795
831
  }
@@ -815,6 +851,47 @@ function newItemText(spec, gate, since) {
815
851
  return serialize(data, body);
816
852
  }
817
853
 
854
+ /**
855
+ * Rank items in the given order: `priority` 1, 2, 3… (lower runs sooner). One commit
856
+ * `gcf(priority)`. A board sends a column's new order after a drag; the console names the ids.
857
+ */
858
+ export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
859
+ if (!ids.length) throw new Error('name the items in the order they should run');
860
+ if (new Set(ids).size !== ids.length) throw new Error('an id is named twice');
861
+ const ledger = await buildLedger(projectDir);
862
+ const items = ids.map((id) => {
863
+ const item = ledger.item(id);
864
+ if (!item) throw new Error(`${id} is not a work item`);
865
+
866
+ return item;
867
+ });
868
+ // A ranked ready item left out of the list (a running one, whose file its run may be editing)
869
+ // keeps its rank: the listed items take the free ranks around it, so no rank is held twice.
870
+ const held = new Set(
871
+ ledger.items
872
+ .filter((i) => i.data.status === 'ready' && !ids.includes(String(i.data.id)))
873
+ .map((i) => i.data.priority)
874
+ .filter(Boolean),
875
+ );
876
+ let rank = 0;
877
+ const ranks = ids.map(() => {
878
+ do rank++;
879
+ while (held.has(rank));
880
+
881
+ return rank;
882
+ });
883
+ const hash = await commitMove(projectDir, `gcf(priority): ${ids.join(', ')}`, {
884
+ files: items.map((i) => i.file),
885
+ mutate: async () => {
886
+ for (const [n, i] of items.entries())
887
+ await atomicWrite(i.file, serialize({ ...i.data, priority: ranks[n] }, i.body));
888
+ },
889
+ commit,
890
+ });
891
+
892
+ return { ids, hash };
893
+ }
894
+
818
895
  /**
819
896
  * A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
820
897
  * not here: its body is the unit's slots. `from` erases the inbox entry it came from.
@@ -838,14 +915,18 @@ export async function newItem(projectDir, spec) {
838
915
  writes.push([p.file, serialize({ ...p.data, children }, p.body)]);
839
916
  }
840
917
 
841
- await withFileRollback(
842
- [...writes.map((w) => w[0]), path.join(root, 'inbox.md'), path.join(root, 'INDEX.md')],
843
- async () => {
918
+ // `commit` is for a caller with no console to commit from, a board: a run starts from HEAD.
919
+ // The inbox is raw input (merge=union): lines added without a commit ride along with it.
920
+ const inbox = spec.from ? [path.join(root, 'inbox.md')] : [];
921
+ const hash = await commitMove(projectDir, `gcf(item): ${spec.id}`, {
922
+ files: [...writes.map((w) => w[0]), ...inbox],
923
+ inputs: inbox,
924
+ mutate: async () => {
844
925
  for (const [f, text] of writes) await atomicWrite(f, text);
845
926
  if (spec.from) await dropInbox(root, spec.from);
846
- await writeIndex(root);
847
927
  },
848
- );
928
+ commit: spec.commit ?? false,
929
+ });
849
930
 
850
- return { item: `gcf/work/${spec.id}.md` };
931
+ return { item: `gcf/work/${spec.id}.md`, hash };
851
932
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.5.3",
3
+ "version": "0.7.0",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,11 +18,12 @@ preconditions:
18
18
  forbidden_behaviors:
19
19
  - do not invent a new subsystem name when INDEX carries a synonym: reuse or ask
20
20
  - do not answer a `TBD` yourself: ask the human, and offer your draft as the proposed answer
21
- - do not take a unit an open D1 blocks: `ai-factory gcf design check` names the blocking question, and the human answers it first
21
+ - do not take a unit an open D1 blocks: `ai-factory gcf design check` names the blocking question, and the human answers it first (`ai-factory gcf question answer <q-id> --text="<answer>"`, or on the board)
22
22
  - do not write a digit in prose: a number is an envelope `a..b <unit>` in `feel` or a closing comparison
23
23
  - do not put `until:` in the frontmatter: it is a line annotation inside slots
24
24
  - do not move a section, edit the backlog or write `status:` by hand: `ai-factory gcf take`, `item new`, `done`, `drop` and the item moves own those
25
25
  - do not reuse an id: an ended id stays ended; rework gets a new id with `reopens: <old id>`
26
+ - do not put an id in `depends` for order alone: `depends` names an item whose data or code this one uses, and order goes in `priority`
26
27
  - do not remove a D2 record cited as `assumed: q-NNN`: it stays in `gcf/open-questions.md` until its `until:` fires; when the human confirms the value earlier, rewrite each citation to `answer: q-NNN` first
27
28
 
28
29
  outputs:
@@ -43,11 +44,15 @@ workflow:
43
44
  10. for `pillars: TBD` propose the ids from `gcf/PILLARS.md` whose statement the unit serves and whose invariant it keeps
44
45
  11. when no pillar fits, ask whether the unit is out of scope or a pillar is missing, and write a D1 question for the answer
45
46
  12. write each answer into the unit in place, with its `answer:` provenance record
46
- 13. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
47
- 14. run `ai-factory gcf take <id>` for one unit, or `ai-factory gcf take <id> <id> --feature <feature-id>` to group several under a feature; it writes `gcf/work/<id>.md` and removes the sections and backlog lines in one step
48
- 15. in each mechanic or resource item write `contract:` from the code tree and `interactions:` from the units and items that write the same state; a soft unit whose human wants no playtest gets `gate: auto` - it will close as unplayed
49
- 16. 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
50
- 17. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
47
+ 13. a record of `gcf/open-questions.md` that carries the human's `answer:` and names this unit in `blocks:` goes into its slot with `answer: q-NNN`; the record then leaves the file
48
+ 14. run `ai-factory gcf design check --json` until the unit's entry in `units` reads `ready: true`, then commit the answered unit
49
+ 15. run `ai-factory gcf take <id>` for one unit, or `ai-factory gcf take <id> <id> --feature <feature-id>` to group several under a feature; it writes `gcf/work/<id>.md` and removes the sections and backlog lines in one step
50
+ 16. in each mechanic or resource item write `contract:` from the code tree and `interactions:` from the units and items that write the same state; a soft unit whose human wants no playtest gets `gate: auto` - it will close as unplayed
51
+ 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
52
+ 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
53
+ 19. two items of one subsystem that change different files get `parallel: ok`, and their `contract:` or `intent:` names those files
54
+ 20. rank the items with `ai-factory gcf prioritize <id> <id> …`: dependencies first, then by value
55
+ 21. report the item paths, each item's gate, and for a verdict item the one question the playtest will answer
51
56
 
52
57
  invocation:
53
58
  - 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
@@ -6,7 +6,7 @@ gcf_context:
6
6
 
7
7
  gcf_work:
8
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
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
10
  - before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
11
11
  - drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
12
12
  - 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
@@ -18,6 +18,9 @@ gcf_work:
18
18
  - 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>"`
19
19
  - 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`
20
20
  - `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
21
+ - 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
22
+ - 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
23
+ - 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`
21
24
  - 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>"`
22
25
 
23
26
  gcf_forbidden: