gcf-aif-ext 0.6.0 → 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,15 @@ 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
+
53
62
  ### 0.6.0
54
63
 
55
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`.
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.
@@ -644,6 +679,7 @@ function registerItem(gcf) {
644
679
  .option('--milestone <name>', 'the milestone of game.md it belongs to')
645
680
  .option('--release <version>', 'a release: the version it ships')
646
681
  .option('--channel <name>', 'a release: where it ships, e.g. steam')
682
+ .option('--priority <rank>', 'its rank: 1, 2, 3… (lower runs sooner)')
647
683
  .option('--model <name>', 'the model its run uses, e.g. opus')
648
684
  .option('--effort <level>', 'the effort its run uses, e.g. high')
649
685
  .option('--commit', 'commit it (gcf(item)): for a caller with no console, e.g. a board')
@@ -666,6 +702,7 @@ function registerItem(gcf) {
666
702
  channel: o.channel,
667
703
  model: o.model,
668
704
  effort: o.effort,
705
+ priority: o.priority === undefined ? undefined : Number(o.priority),
669
706
  commit: o.commit === true,
670
707
  });
671
708
  console.log(`${r.item}${shortHash(r.hash)}`);
@@ -674,11 +711,34 @@ function registerItem(gcf) {
674
711
 
675
712
  function registerItemMoves(gcf) {
676
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
+ });
677
735
  gcf
678
736
  .command('submit <id>')
679
737
  .description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
738
+ .option('--claimed', CLAIMED)
680
739
  .option('--no-commit', 'skip the commit (tests only)')
681
740
  .action(async (id, o) => {
741
+ claimedBy(o, id);
682
742
  const r = await (await ops()).submitItem(process.cwd(), id, { commit: o.commit });
683
743
  console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
684
744
  });
@@ -691,8 +751,10 @@ function registerItemMoves(gcf) {
691
751
  (v, p) => [...p, v],
692
752
  [],
693
753
  )
754
+ .option('--claimed', CLAIMED)
694
755
  .option('--no-commit', 'skip the commit (tests only)')
695
756
  .action(async (id, o) => {
757
+ claimedBy(o, id);
696
758
  const r = await (
697
759
  await ops()
698
760
  ).closeItem(process.cwd(), id, { constraints: o.constraint, commit: o.commit });
@@ -723,8 +785,10 @@ function registerDecide(gcf) {
723
785
  .command('block <id>')
724
786
  .description('ready → blocked, with the reason or the question (commit gcf(block))')
725
787
  .requiredOption('--reason <text>', 'why it cannot go on, or the question for you')
788
+ .option('--claimed', CLAIMED)
726
789
  .option('--no-commit', 'skip the commit (tests only)')
727
790
  .action(async (id, o) => {
791
+ claimedBy(o, id);
728
792
  const r = await (
729
793
  await ops()
730
794
  ).blockItem(process.cwd(), id, { reason: o.reason, commit: o.commit });
@@ -857,6 +921,7 @@ function registerPlan(gcf) {
857
921
  .description('Deterministic plan check: it exists, has task checkboxes, readset in budget')
858
922
  .option('--json', 'machine output')
859
923
  .action(async (id, o) => {
924
+ await touchSession(id);
860
925
  const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
861
926
  out(o.json, r, (x) =>
862
927
  x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
@@ -872,6 +937,7 @@ function registerVerify(gcf) {
872
937
  .option('--mark', 'in its worktree: green and committed → commit the GCF-Verified trailer')
873
938
  .option('--json', 'machine output')
874
939
  .action(async (id, o) => {
940
+ await touchSession(id);
875
941
  const r = o.mark
876
942
  ? await (await lib('integrate')).markVerified(process.cwd(), id)
877
943
  : await (await lib('verify')).verifyItem(process.cwd(), id);
@@ -1059,6 +1125,7 @@ export function register(program) {
1059
1125
  registerMoves(gcf);
1060
1126
  registerItem(gcf);
1061
1127
  registerItemMoves(gcf);
1128
+ registerSession(gcf);
1062
1129
  registerDecide(gcf);
1063
1130
  registerInbox(gcf);
1064
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.6.0",
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
@@ -14,6 +14,8 @@ import { wipOf } from './audit-core.js';
14
14
  import { runHolder } from './main-lock.js';
15
15
  import { ITEM_TYPES } from './validate-item.js';
16
16
  import { movesOf } from './transitions.js';
17
+ import { listQuestions } from './questions.js';
18
+ import { sessionActive } from './session-mark.js';
17
19
 
18
20
  // The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
19
21
  // when a field changes meaning or goes away; a new field does not bump it.
@@ -95,21 +97,6 @@ function verifiedOf(projectDir) {
95
97
  }
96
98
  }
97
99
 
98
- /** Open question records of open-questions.md: id and class. */
99
- async function questions(root) {
100
- const file = path.join(root, 'open-questions.md');
101
- if (!existsSync(file)) return [];
102
-
103
- return (await readFile(file, 'utf8'))
104
- .replace(/\r\n/g, '\n')
105
- .split(/\n(?=- id:)/)
106
- .flatMap((rec) => {
107
- const id = rec.match(/^- id:\s*(\S+)/)?.[1];
108
-
109
- return id ? [{ id, class: rec.match(/^\s+class:\s*(\S+)/m)?.[1] ?? null }] : [];
110
- });
111
- }
112
-
113
100
  /** The phase status, or the reason it cannot be read: the board still shows the rest. */
114
101
  async function phaseOf(projectDir) {
115
102
  try {
@@ -134,7 +121,19 @@ function logOf(projectDir, id) {
134
121
  }
135
122
 
136
123
  // Fields of the item file a board shows as they are: null or [] when the file has none.
137
- const SCALARS = ['title', 'parent', 'milestone', 'tune_count', 'since', 'model', 'effort'];
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);
138
137
  const LISTS = ['children', 'system', 'depends'];
139
138
 
140
139
  /** One item of the board. */
@@ -146,6 +145,7 @@ async function itemOf(projectDir, c, verified) {
146
145
  // item, is a run; a trailer is a passed DoD.
147
146
  const running =
148
147
  runHolder(projectDir, id) !== null ||
148
+ sessionActive(projectDir, id) ||
149
149
  (c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id)));
150
150
 
151
151
  return {
@@ -173,12 +173,14 @@ export async function board(projectDir) {
173
173
  const verified = verifiedOf(projectDir);
174
174
  const items = [];
175
175
  for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
176
+ items.sort(byPriority);
176
177
 
177
178
  const config = await readConfig(root);
178
179
  const live = await readLive(root);
179
180
 
180
181
  return {
181
182
  schema: BOARD_SCHEMA,
183
+ run_mode: config.run_mode,
182
184
  phase: await phaseOf(projectDir),
183
185
  // A malformed block reads with fields missing (live check names it): null, not absent.
184
186
  release: releaseOf(live.release),
@@ -188,7 +190,7 @@ export async function board(projectDir) {
188
190
  items,
189
191
  wip: wipOf(ledger.items, config),
190
192
  inbox: await listInbox(root),
191
- questions: await questions(root),
193
+ questions: await listQuestions(root),
192
194
  history: await historyOf(root),
193
195
  graveyard: await graveyardOf(root),
194
196
  };
@@ -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') : '';
@@ -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
+ }
@@ -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
@@ -38,6 +38,7 @@ import {
38
38
  import { dropInbox } from './inbox.js';
39
39
  import { refuseByPhase } from './phase.js';
40
40
  import { runHolder } from './main-lock.js';
41
+ import { releaseSession, sessionActive } from './session-mark.js';
41
42
 
42
43
  /**
43
44
  * Options of a committed move; each move reads the ones it needs.
@@ -173,6 +174,13 @@ async function begin(projectDir, id, move) {
173
174
  `${id} is driven by a harness run (pid ${run}): wait until that run ends; if no harness ` +
174
175
  `run is going, delete .gcf-session/runs/${id}.lock`,
175
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
+ );
176
184
  const files = [item.file];
177
185
  const steps = [];
178
186
  const liveOps = [];
@@ -202,13 +210,18 @@ async function begin(projectDir, id, move) {
202
210
  }
203
211
 
204
212
  /** Commit the move as `gcf(<move>): <tail>` with every file it touched (item edits included). */
205
- const finish = (m, move, tail, commit) =>
206
- commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
213
+ async function finish(m, move, tail, commit) {
214
+ const hash = await commitMove(m.projectDir, `gcf(${move}): ${tail}`, {
207
215
  files: m.files,
208
216
  mutate: m.run,
209
217
  commit,
210
218
  inputs: [m.item.file],
211
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
+ }
212
225
 
213
226
  const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
214
227
 
@@ -742,7 +755,10 @@ function checkRelease(spec) {
742
755
 
743
756
  const RUN_KEYS = ['model', 'effort'];
744
757
 
745
- /** The model and effort a new item gives: the run passes them to the agent's command line. */
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
+ */
746
762
  function runFields(spec) {
747
763
  for (const k of RUN_KEYS)
748
764
  if (spec[k] !== undefined && !RUN_WORD.test(spec[k]))
@@ -750,8 +766,13 @@ function runFields(spec) {
750
766
  `--${k} is one word that starts with a letter or a digit ` +
751
767
  '(then letters, digits, dots, hyphens, underscores), e.g. opus',
752
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)');
753
772
 
754
- return Object.fromEntries(RUN_KEYS.filter((k) => spec[k]).map((k) => [k, spec[k]]));
773
+ return Object.fromEntries(
774
+ [...RUN_KEYS, 'priority'].filter((k) => spec[k] !== undefined).map((k) => [k, spec[k]]),
775
+ );
755
776
  }
756
777
 
757
778
  /** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
@@ -830,6 +851,47 @@ function newItemText(spec, gate, since) {
830
851
  return serialize(data, body);
831
852
  }
832
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
+
833
895
  /**
834
896
  * A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
835
897
  * not here: its body is the unit's slots. `from` erases the inbox entry it came from.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.6.0",
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
@@ -19,6 +19,8 @@ gcf_work:
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
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`
22
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>"`
23
25
 
24
26
  gcf_forbidden: