gcf-aif-ext 0.7.3 → 0.7.5

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
@@ -52,7 +52,7 @@ On Windows, run `extension add` and `extension update` from PowerShell or cmd, n
52
52
 
53
53
  ### 0.7.1
54
54
 
55
- - `gcf board --json`: a ready item's worktree with no run lock and no session mark is a run that stopped (a timeout, a stop, a crash), not a run: the item reads `running: false`, `resumable: true`, and keeps its moves; its next run resumes the worktree.
55
+ - `gcf board --json`: a ready item's worktree with no run lock and no session mark is a run that stopped (a timeout, a stop, a crash), not a run: the item reads `running: false`, `resumable: true`, and keeps its moves; its next run resumes the worktree. An agent session the harness kept (`.gcf-session/runs/<id>.agent.json`) makes a ready item `resumable` too, in either run mode, and shows as `resume: {ended, resets?}` (null without one).
56
56
 
57
57
  ### 0.7.0
58
58
 
package/extension.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
3
3
  "name": "gcf-aif-ext",
4
- "version": "0.7.3",
4
+ "version": "0.7.5",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
package/lib/board.js CHANGED
@@ -120,6 +120,25 @@ function logOf(projectDir, id) {
120
120
  return existsSync(path.join(projectDir, log)) ? log : null;
121
121
  }
122
122
 
123
+ /**
124
+ * How the agent session of the item's last run ended, when the harness kept it for a rerun to
125
+ * resume (`<id>.agent.json`): `{ended, resets?}`, or null. A file that is not a record is none.
126
+ */
127
+ async function resumeOf(projectDir, id) {
128
+ try {
129
+ const file = path.join(projectDir, '.gcf-session', 'runs', `${id}.agent.json`);
130
+ const rec = JSON.parse(await readFile(file, 'utf8'));
131
+ if (typeof rec?.session_id !== 'string') return null;
132
+
133
+ return {
134
+ ended: String(rec.ended ?? ''),
135
+ ...(rec.resets ? { resets: String(rec.resets) } : {}),
136
+ };
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
123
142
  // Fields of the item file a board shows as they are: null or [] when the file has none.
124
143
  const SCALARS = [
125
144
  'title',
@@ -163,10 +182,15 @@ async function itemOf(projectDir, c, verified) {
163
182
  const ready = c.data.status === 'ready' && verified.has(id);
164
183
  // `running` and `verify` are not stored (§4): a live harness run lock or a fresh session mark
165
184
  // is a run; a trailer is a passed DoD. A worktree with neither is a run that stopped (a
166
- // timeout, a stop, a crash): the item is ready, and its next run resumes that worktree.
185
+ // timeout, a stop, a crash): the item is ready, and its next run resumes that worktree. A
186
+ // session the harness kept (`resume`) is resumed by the next run in either mode.
167
187
  const running = runHolder(projectDir, id) !== null || sessionActive(projectDir, id);
188
+ const resume = c.data.status === 'ready' ? await resumeOf(projectDir, id) : null;
168
189
  const resumable =
169
- !running && c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id));
190
+ !running &&
191
+ c.data.status === 'ready' &&
192
+ !ready &&
193
+ (resume !== null || existsSync(worktreeOf(projectDir, id)));
170
194
 
171
195
  return {
172
196
  id,
@@ -179,6 +203,7 @@ async function itemOf(projectDir, c, verified) {
179
203
  ...(c.data.blocked ? { blocked: c.data.blocked } : {}),
180
204
  running,
181
205
  resumable,
206
+ resume,
182
207
  verified: ready,
183
208
  // What a board may offer (§4.1): nothing while a run drives the item.
184
209
  moves: running ? [] : movesOf(c.data),
package/lib/bundle.js CHANGED
@@ -180,13 +180,21 @@ function plan(bundle, cur, ledger) {
180
180
  const writes = [];
181
181
  const note = `lines outside any part: ${bundle.stray.join(', ')} (a lost gcf:part marker?)`;
182
182
  const report = bundle.stray.length ? [{ part: 'bundle', outcome: 'invalid', note }] : [];
183
+ const keyOf = (p) => `${p.kind}:${p.name}`;
184
+ const keys = bundle.parts.map(keyOf);
183
185
  for (const p of bundle.parts) {
184
- const key = `${p.kind}:${p.name}`;
186
+ const key = keyOf(p);
185
187
  if (!validName(p)) {
186
188
  report.push({ part: key, outcome: 'invalid', note: 'not a gcf doc, system or unit name' });
187
189
  continue;
188
190
  }
189
191
 
192
+ // Which copy is the designer's edit is not ours to guess: the later one would win silently.
193
+ if (keys.indexOf(key) !== keys.lastIndexOf(key)) {
194
+ report.push({ part: key, outcome: 'invalid', note: 'the bundle holds this part twice' });
195
+ continue;
196
+ }
197
+
190
198
  const v = verdict(p, cur.get(key) ?? null);
191
199
  if (p.kind === 'unit' && v === 'new' && ledger.known(p.name)) {
192
200
  report.push({ part: key, outcome: 'taken', note: 'the id is used: pick another slug' });
package/lib/ledger.js CHANGED
@@ -71,9 +71,11 @@ export async function buildLedger(projectDir) {
71
71
  for (const d of design) for (const u of d.units) add(u.id, { state: 'design', file: d.file });
72
72
  for (const c of items) add(String(c.data.id), { state: 'item', file: `work/${c.name}` });
73
73
 
74
- /** @type {Map<string, Set<string>>} */
74
+ // A list, not a set: two equal lines are two ends too (M12), a merge of branches that each
75
+ // ended the id the same way.
76
+ /** @type {Map<string, string[]>} */
75
77
  const terminal = new Map();
76
- const mark = (id, event) => terminal.set(id, (terminal.get(id) ?? new Set()).add(event));
78
+ const mark = (id, event) => terminal.set(id, [...(terminal.get(id) ?? []), event]);
77
79
  for (const [id, event] of await recordLines(root, 'history.md'))
78
80
  if (HISTORY_EVENTS.includes(event)) mark(id, event);
79
81
  for (const [id] of await recordLines(root, 'graveyard.md')) mark(id, 'killed');
@@ -40,6 +40,14 @@ export function releaseSession(projectDir, id) {
40
40
  rmSync(markOf(projectDir, id), { force: true });
41
41
  }
42
42
 
43
+ /**
44
+ * Forget the agent session the harness kept for a rerun of `id` (`<id>.agent.json`): a move,
45
+ * by hand or by an agent, ends the work it would resume.
46
+ */
47
+ export function dropAgentSession(projectDir, id) {
48
+ rmSync(path.join(path.dirname(markOf(projectDir, id)), `${id}.agent.json`), { force: true });
49
+ }
50
+
43
51
  /** Whether a session works the item now: a mark refreshed within SESSION_TTL_MS. */
44
52
  export function sessionActive(projectDir, id) {
45
53
  const file = markOf(projectDir, id);
package/lib/work-ops.js CHANGED
@@ -11,7 +11,7 @@ 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 } from './safe-path.js';
14
+ import { atomicWrite, resolveInside } from './safe-path.js';
15
15
  import { allowedFrom, decisionFits, FEATURE_ONLY, fromOf } from './transitions.js';
16
16
  import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
17
17
  import { isValidId } from './id.js';
@@ -37,8 +37,8 @@ import {
37
37
  } from './live.js';
38
38
  import { dropInbox } from './inbox.js';
39
39
  import { refuseByPhase } from './phase.js';
40
- import { runHolder } from './main-lock.js';
41
- import { releaseSession, sessionActive } from './session-mark.js';
40
+ import { runHolder, withMainLock } from './main-lock.js';
41
+ import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
42
42
 
43
43
  /**
44
44
  * Options of a committed move; each move reads the ones it needs.
@@ -160,14 +160,13 @@ function unfinishedChildren(ledger, feature) {
160
160
  }
161
161
 
162
162
  /**
163
- * Open a move on item `id` from the statuses `move` allows: the files it touches, its steps,
164
- * and the LIVE.md edits folded into a single write at the end.
163
+ * Refuse a move on item `id` while a harness run or another agent session works it. `self` is
164
+ * the item this session drives itself (null: none).
165
+ * @param {string} projectDir
166
+ * @param {string} id
167
+ * @param {string | null} [self]
165
168
  */
166
- async function begin(projectDir, id, move) {
167
- const root = gcfOf(projectDir);
168
- const ledger = await buildLedger(projectDir);
169
- const item = itemOf(ledger, id);
170
- need(move, item);
169
+ function refuseRunning(projectDir, id, self = process.env.GCF_RUN_ITEM ?? null) {
171
170
  const run = runHolder(projectDir, id);
172
171
  if (run)
173
172
  throw new Error(
@@ -176,11 +175,23 @@ async function begin(projectDir, id, move) {
176
175
  );
177
176
  // The session that claimed the item moves it itself (GCF_RUN_ITEM, set by `--claimed` or by
178
177
  // a harness run on main); anyone else waits, as for a run lock.
179
- if (sessionActive(projectDir, id) && process.env.GCF_RUN_ITEM !== id)
178
+ if (sessionActive(projectDir, id) && self !== id)
180
179
  throw new Error(
181
180
  `${id} is worked by an agent session (gcf claim): wait until it moves the item, or run ` +
182
181
  `ai-factory gcf release ${id} when no session works it`,
183
182
  );
183
+ }
184
+
185
+ /**
186
+ * Open a move on item `id` from the statuses `move` allows: the files it touches, its steps,
187
+ * and the LIVE.md edits folded into a single write at the end.
188
+ */
189
+ async function begin(projectDir, id, move) {
190
+ const root = gcfOf(projectDir);
191
+ const ledger = await buildLedger(projectDir);
192
+ const item = itemOf(ledger, id);
193
+ need(move, item);
194
+ refuseRunning(projectDir, id);
184
195
  const files = [item.file];
185
196
  const steps = [];
186
197
  const liveOps = [];
@@ -217,8 +228,10 @@ async function finish(m, move, tail, commit) {
217
228
  commit,
218
229
  inputs: [m.item.file],
219
230
  });
220
- // A move on the item ends the session that worked it: the board stops showing it running.
231
+ // A move on the item ends the session that worked it: the board stops showing it running,
232
+ // and no rerun resumes it.
221
233
  releaseSession(m.projectDir, String(m.item.data.id));
234
+ dropAgentSession(m.projectDir, String(m.item.data.id));
222
235
 
223
236
  return hash;
224
237
  }
@@ -229,6 +242,16 @@ const writeItem = (item, data, body = item.body) => atomicWrite(item.file, seria
229
242
  const without = (data, ...keys) =>
230
243
  Object.fromEntries(Object.entries(data).filter(([k]) => !keys.includes(k)));
231
244
 
245
+ /** Refuse a move on an unplayed item edited since its close; `then` is where the edit goes. */
246
+ function refuseEdited(item, then) {
247
+ if (matchesFrozen(item)) return;
248
+ throw new Error(
249
+ `${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
250
+ `save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
251
+ `discards it), make the move, then ${then}`,
252
+ );
253
+ }
254
+
232
255
  /**
233
256
  * The item's lines leave its systems docs and its added capability leaves LIVE.md. A line
234
257
  * another unplayed item also wrote stays: that item's body still owes it to systems/.
@@ -236,12 +259,7 @@ const without = (data, ...keys) =>
236
259
  function unplay(m, item) {
237
260
  // The gate checks `frozen` only on a file that stays unplayed: a tune or a kill of an edited
238
261
  // item would take the edited lines out and leave the frozen ones in systems/ for good.
239
- if (!matchesFrozen(item))
240
- throw new Error(
241
- `${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
242
- `save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
243
- 'discards it), make the move, then apply the edit to the ready item',
244
- );
262
+ refuseEdited(item, 'apply the edit to the ready item');
245
263
  const systems = item.data.system ?? [];
246
264
  m.leaving.add(String(item.data.id));
247
265
  m.touch(...systemFiles(m.root, systems));
@@ -283,7 +301,8 @@ function tracked(projectDir, file) {
283
301
  */
284
302
  function archivePlan(m, item) {
285
303
  const rel = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
286
- const src = path.resolve(m.projectDir, rel);
304
+ // The plan is moved and deleted: a `plan:` out of the project would take another tree's file.
305
+ const src = resolveInside(m.projectDir, rel);
287
306
  if (!existsSync(src) || !tracked(m.projectDir, src)) return null;
288
307
  const destRel = `.ai-factory/archive/plans/${path.basename(src)}`;
289
308
  const dest = path.resolve(m.projectDir, destRel);
@@ -456,7 +475,7 @@ function refuseOpenDepends(m, move, opts = {}) {
456
475
  /** ready → awaiting-verdict / awaiting-decision; `since` marks where the wait began.
457
476
  * @param {MoveOpts} [opts]
458
477
  */
459
- export async function submitItem(projectDir, id, opts = {}) {
478
+ async function submitItemUnlocked(projectDir, id, opts = {}) {
460
479
  const { commit = true } = opts;
461
480
  const m = await begin(projectDir, id, 'submit');
462
481
  if (m.item.data.gate === 'auto')
@@ -528,7 +547,7 @@ function closeFeature(m) {
528
547
  * unit collapses into its systems docs and stays as unplayed; anything else ends in history.
529
548
  * @param {MoveOpts} [opts]
530
549
  */
531
- export async function closeItem(projectDir, id, opts = {}) {
550
+ async function closeItemUnlocked(projectDir, id, opts = {}) {
532
551
  const { constraints = [], commit = true } = opts;
533
552
  const m = await begin(projectDir, id, 'close');
534
553
  const unplayed = SOFT.includes(m.item.data.type);
@@ -554,6 +573,8 @@ async function keep(m, constraints) {
554
573
  const { item } = m;
555
574
  refuseOpenDepends(m, 'verdict keep');
556
575
  if (item.data.status === 'unplayed') {
576
+ // systems/ and LIVE.md hold the frozen body: an edit made since would leave with the file.
577
+ refuseEdited(item, `carry the edit in a new item with reopens: ${item.data.id}`);
557
578
  endToHistory(m, item, 'collapsed', { applyLive: false });
558
579
 
559
580
  return;
@@ -599,7 +620,7 @@ async function tune(m, note) {
599
620
  /** keep | tune | kill on an item waiting for its verdict or unplayed (Methodology §5.4).
600
621
  * @param {MoveOpts} [opts]
601
622
  */
602
- export async function verdictItem(projectDir, id, verdict, opts = {}) {
623
+ async function verdictItemUnlocked(projectDir, id, verdict, opts = {}) {
603
624
  const { note = '', tags = [], remove, constraints = [], commit = true } = opts;
604
625
  if (!['keep', 'tune', 'kill'].includes(verdict)) throw new Error(`Unknown verdict: ${verdict}`);
605
626
  const m = await begin(projectDir, id, `verdict:${verdict}`);
@@ -617,9 +638,11 @@ export async function verdictItem(projectDir, id, verdict, opts = {}) {
617
638
  function killFeature(m, { reason, remove }) {
618
639
  const { item, ledger } = m;
619
640
  const shipped = featureShipped(ledger, item);
620
- for (const c of item.data.children ?? []) {
621
- const child = ledger.item(c);
622
- if (!child) continue;
641
+ const children = (item.data.children ?? []).map((c) => ledger.item(c)).filter(Boolean);
642
+ // A child's file goes with the feature: an agent working it would lose it mid-run.
643
+ // No session kills its own item through its feature: the claimed child counts as running too.
644
+ for (const child of children) refuseRunning(m.projectDir, String(child.data.id), null);
645
+ for (const child of children) {
623
646
  endToGraveyard(m, child, {
624
647
  reason: `feature ${item.data.id} killed: ${reason}`,
625
648
  removal: false,
@@ -674,7 +697,7 @@ function fitDecision(choice, item) {
674
697
  /** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
675
698
  * @param {MoveOpts} [opts]
676
699
  */
677
- export async function decideItem(projectDir, id, choice, opts = {}) {
700
+ async function decideItemUnlocked(projectDir, id, choice, opts = {}) {
678
701
  const { reason = '', remove, commit = true } = opts;
679
702
  if (!DECIDE[choice])
680
703
  throw new Error(`Unknown decision: ${choice} (${Object.keys(DECIDE).join(' | ')})`);
@@ -689,7 +712,7 @@ export async function decideItem(projectDir, id, choice, opts = {}) {
689
712
  /** ready → blocked, with the reason or the question for the operator.
690
713
  * @param {MoveOpts} [opts]
691
714
  */
692
- export async function blockItem(projectDir, id, opts = {}) {
715
+ async function blockItemUnlocked(projectDir, id, opts = {}) {
693
716
  const { reason, commit = true } = opts;
694
717
  const why = String(reason ?? '').trim();
695
718
  if (!why) throw new Error('a block needs a reason or a question');
@@ -703,7 +726,7 @@ export async function blockItem(projectDir, id, opts = {}) {
703
726
  /** blocked | parked → ready; a note (an answer, a delivered asset file) goes to `notes:`.
704
727
  * @param {MoveOpts} [opts]
705
728
  */
706
- export async function unblockItem(projectDir, id, opts = {}) {
729
+ async function unblockItemUnlocked(projectDir, id, opts = {}) {
707
730
  const { note = '', commit = true } = opts;
708
731
  const m = await begin(projectDir, id, 'unblock');
709
732
  if (m.item.data.status === 'parked') dropKnownIssue(m, id);
@@ -725,7 +748,7 @@ export async function unblockItem(projectDir, id, opts = {}) {
725
748
  /** Any stored status → graveyard; an unplayed item leaves as a kill does.
726
749
  * @param {MoveOpts} [opts]
727
750
  */
728
- export async function dropItem(projectDir, id, opts = {}) {
751
+ async function dropItemUnlocked(projectDir, id, opts = {}) {
729
752
  const { reason, tags = [], remove, commit = true } = opts;
730
753
  const m = await begin(projectDir, id, 'drop');
731
754
  if (m.item.data.type === 'feature' && (m.item.data.children ?? []).some((c) => m.ledger.item(c)))
@@ -855,7 +878,7 @@ function newItemText(spec, gate, since) {
855
878
  * Rank items in the given order: `priority` 1, 2, 3… (lower runs sooner). One commit
856
879
  * `gcf(priority)`. A board sends a column's new order after a drag; the console names the ids.
857
880
  */
858
- export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
881
+ async function prioritizeItemsUnlocked(projectDir, ids, { commit = true } = {}) {
859
882
  if (!ids.length) throw new Error('name the items in the order they should run');
860
883
  if (new Set(ids).size !== ids.length) throw new Error('an id is named twice');
861
884
  const ledger = await buildLedger(projectDir);
@@ -896,7 +919,7 @@ export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
896
919
  * A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
897
920
  * not here: its body is the unit's slots. `from` erases the inbox entry it came from.
898
921
  */
899
- export async function newItem(projectDir, spec) {
922
+ async function newItemUnlocked(projectDir, spec) {
900
923
  const root = gcfOf(projectDir);
901
924
  const ledger = await buildLedger(projectDir);
902
925
  const gate = checkNew(ledger, spec);
@@ -930,3 +953,41 @@ export async function newItem(projectDir, spec) {
930
953
 
931
954
  return { item: `gcf/work/${spec.id}.md`, hash };
932
955
  }
956
+
957
+ /**
958
+ * A committed move holds the main lock from its first read to its commit (Methodology §7): one
959
+ * that read the ledger before taking it would write over a move committed meanwhile.
960
+ * @template T
961
+ * @param {string} projectDir
962
+ * @param {boolean} commit
963
+ * @param {() => Promise<T>} move
964
+ */
965
+ const locked = (projectDir, commit, move) => (commit ? withMainLock(projectDir, move) : move());
966
+
967
+ /** @type {typeof submitItemUnlocked} */
968
+ export const submitItem = (projectDir, id, opts = {}) =>
969
+ locked(projectDir, opts.commit ?? true, () => submitItemUnlocked(projectDir, id, opts));
970
+ /** @type {typeof closeItemUnlocked} */
971
+ export const closeItem = (projectDir, id, opts = {}) =>
972
+ locked(projectDir, opts.commit ?? true, () => closeItemUnlocked(projectDir, id, opts));
973
+ /** @type {typeof verdictItemUnlocked} */
974
+ export const verdictItem = (projectDir, id, verdict, opts = {}) =>
975
+ locked(projectDir, opts.commit ?? true, () => verdictItemUnlocked(projectDir, id, verdict, opts));
976
+ /** @type {typeof decideItemUnlocked} */
977
+ export const decideItem = (projectDir, id, choice, opts = {}) =>
978
+ locked(projectDir, opts.commit ?? true, () => decideItemUnlocked(projectDir, id, choice, opts));
979
+ /** @type {typeof blockItemUnlocked} */
980
+ export const blockItem = (projectDir, id, opts = {}) =>
981
+ locked(projectDir, opts.commit ?? true, () => blockItemUnlocked(projectDir, id, opts));
982
+ /** @type {typeof unblockItemUnlocked} */
983
+ export const unblockItem = (projectDir, id, opts = {}) =>
984
+ locked(projectDir, opts.commit ?? true, () => unblockItemUnlocked(projectDir, id, opts));
985
+ /** @type {typeof dropItemUnlocked} */
986
+ export const dropItem = (projectDir, id, opts = {}) =>
987
+ locked(projectDir, opts.commit ?? true, () => dropItemUnlocked(projectDir, id, opts));
988
+ /** @type {typeof prioritizeItemsUnlocked} */
989
+ export const prioritizeItems = (projectDir, ids, opts = {}) =>
990
+ locked(projectDir, opts.commit ?? true, () => prioritizeItemsUnlocked(projectDir, ids, opts));
991
+ /** @type {typeof newItemUnlocked} */
992
+ export const newItem = (projectDir, spec) =>
993
+ locked(projectDir, spec.commit ?? false, () => newItemUnlocked(projectDir, spec));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gcf-aif-ext",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",