gcf-aif-ext 0.7.2 → 0.7.4

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/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.2",
4
+ "version": "0.7.4",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
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/integrate.js CHANGED
@@ -302,6 +302,8 @@ export async function integrate(projectDir, ids = []) {
302
302
  throw new Error(`integrate runs in the main checkout on its branch, not on ${branch}`);
303
303
 
304
304
  return withMainLock(projectDir, async () => {
305
+ // Nothing to merge leaves main alone: a run's first integrate must not refuse a dirty tree.
306
+ if (!ids.length && !verifiedBranches(projectDir).length) return [];
305
307
  if (git(projectDir, ['status', '--porcelain', '--untracked-files=no', '--', '.', METRICS]).out)
306
308
  throw new Error('main has uncommitted changes: commit or stash them before integrating');
307
309
  commitMetrics(projectDir);
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');
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,7 +37,7 @@ 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';
40
+ import { runHolder, withMainLock } from './main-lock.js';
41
41
  import { releaseSession, sessionActive } from './session-mark.js';
42
42
 
43
43
  /**
@@ -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 = [];
@@ -229,6 +240,16 @@ const writeItem = (item, data, body = item.body) => atomicWrite(item.file, seria
229
240
  const without = (data, ...keys) =>
230
241
  Object.fromEntries(Object.entries(data).filter(([k]) => !keys.includes(k)));
231
242
 
243
+ /** Refuse a move on an unplayed item edited since its close; `then` is where the edit goes. */
244
+ function refuseEdited(item, then) {
245
+ if (matchesFrozen(item)) return;
246
+ throw new Error(
247
+ `${item.data.id}: an unplayed item's body and live: are frozen, and this one was edited — ` +
248
+ `save the edit aside, restore the file (git restore gcf/work/${item.data.id}.md ` +
249
+ `discards it), make the move, then ${then}`,
250
+ );
251
+ }
252
+
232
253
  /**
233
254
  * The item's lines leave its systems docs and its added capability leaves LIVE.md. A line
234
255
  * another unplayed item also wrote stays: that item's body still owes it to systems/.
@@ -236,12 +257,7 @@ const without = (data, ...keys) =>
236
257
  function unplay(m, item) {
237
258
  // The gate checks `frozen` only on a file that stays unplayed: a tune or a kill of an edited
238
259
  // 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
- );
260
+ refuseEdited(item, 'apply the edit to the ready item');
245
261
  const systems = item.data.system ?? [];
246
262
  m.leaving.add(String(item.data.id));
247
263
  m.touch(...systemFiles(m.root, systems));
@@ -283,7 +299,8 @@ function tracked(projectDir, file) {
283
299
  */
284
300
  function archivePlan(m, item) {
285
301
  const rel = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
286
- const src = path.resolve(m.projectDir, rel);
302
+ // The plan is moved and deleted: a `plan:` out of the project would take another tree's file.
303
+ const src = resolveInside(m.projectDir, rel);
287
304
  if (!existsSync(src) || !tracked(m.projectDir, src)) return null;
288
305
  const destRel = `.ai-factory/archive/plans/${path.basename(src)}`;
289
306
  const dest = path.resolve(m.projectDir, destRel);
@@ -456,7 +473,7 @@ function refuseOpenDepends(m, move, opts = {}) {
456
473
  /** ready → awaiting-verdict / awaiting-decision; `since` marks where the wait began.
457
474
  * @param {MoveOpts} [opts]
458
475
  */
459
- export async function submitItem(projectDir, id, opts = {}) {
476
+ async function submitItemUnlocked(projectDir, id, opts = {}) {
460
477
  const { commit = true } = opts;
461
478
  const m = await begin(projectDir, id, 'submit');
462
479
  if (m.item.data.gate === 'auto')
@@ -528,7 +545,7 @@ function closeFeature(m) {
528
545
  * unit collapses into its systems docs and stays as unplayed; anything else ends in history.
529
546
  * @param {MoveOpts} [opts]
530
547
  */
531
- export async function closeItem(projectDir, id, opts = {}) {
548
+ async function closeItemUnlocked(projectDir, id, opts = {}) {
532
549
  const { constraints = [], commit = true } = opts;
533
550
  const m = await begin(projectDir, id, 'close');
534
551
  const unplayed = SOFT.includes(m.item.data.type);
@@ -554,6 +571,8 @@ async function keep(m, constraints) {
554
571
  const { item } = m;
555
572
  refuseOpenDepends(m, 'verdict keep');
556
573
  if (item.data.status === 'unplayed') {
574
+ // systems/ and LIVE.md hold the frozen body: an edit made since would leave with the file.
575
+ refuseEdited(item, `carry the edit in a new item with reopens: ${item.data.id}`);
557
576
  endToHistory(m, item, 'collapsed', { applyLive: false });
558
577
 
559
578
  return;
@@ -599,7 +618,7 @@ async function tune(m, note) {
599
618
  /** keep | tune | kill on an item waiting for its verdict or unplayed (Methodology §5.4).
600
619
  * @param {MoveOpts} [opts]
601
620
  */
602
- export async function verdictItem(projectDir, id, verdict, opts = {}) {
621
+ async function verdictItemUnlocked(projectDir, id, verdict, opts = {}) {
603
622
  const { note = '', tags = [], remove, constraints = [], commit = true } = opts;
604
623
  if (!['keep', 'tune', 'kill'].includes(verdict)) throw new Error(`Unknown verdict: ${verdict}`);
605
624
  const m = await begin(projectDir, id, `verdict:${verdict}`);
@@ -617,9 +636,11 @@ export async function verdictItem(projectDir, id, verdict, opts = {}) {
617
636
  function killFeature(m, { reason, remove }) {
618
637
  const { item, ledger } = m;
619
638
  const shipped = featureShipped(ledger, item);
620
- for (const c of item.data.children ?? []) {
621
- const child = ledger.item(c);
622
- if (!child) continue;
639
+ const children = (item.data.children ?? []).map((c) => ledger.item(c)).filter(Boolean);
640
+ // A child's file goes with the feature: an agent working it would lose it mid-run.
641
+ // No session kills its own item through its feature: the claimed child counts as running too.
642
+ for (const child of children) refuseRunning(m.projectDir, String(child.data.id), null);
643
+ for (const child of children) {
623
644
  endToGraveyard(m, child, {
624
645
  reason: `feature ${item.data.id} killed: ${reason}`,
625
646
  removal: false,
@@ -674,7 +695,7 @@ function fitDecision(choice, item) {
674
695
  /** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
675
696
  * @param {MoveOpts} [opts]
676
697
  */
677
- export async function decideItem(projectDir, id, choice, opts = {}) {
698
+ async function decideItemUnlocked(projectDir, id, choice, opts = {}) {
678
699
  const { reason = '', remove, commit = true } = opts;
679
700
  if (!DECIDE[choice])
680
701
  throw new Error(`Unknown decision: ${choice} (${Object.keys(DECIDE).join(' | ')})`);
@@ -689,7 +710,7 @@ export async function decideItem(projectDir, id, choice, opts = {}) {
689
710
  /** ready → blocked, with the reason or the question for the operator.
690
711
  * @param {MoveOpts} [opts]
691
712
  */
692
- export async function blockItem(projectDir, id, opts = {}) {
713
+ async function blockItemUnlocked(projectDir, id, opts = {}) {
693
714
  const { reason, commit = true } = opts;
694
715
  const why = String(reason ?? '').trim();
695
716
  if (!why) throw new Error('a block needs a reason or a question');
@@ -703,7 +724,7 @@ export async function blockItem(projectDir, id, opts = {}) {
703
724
  /** blocked | parked → ready; a note (an answer, a delivered asset file) goes to `notes:`.
704
725
  * @param {MoveOpts} [opts]
705
726
  */
706
- export async function unblockItem(projectDir, id, opts = {}) {
727
+ async function unblockItemUnlocked(projectDir, id, opts = {}) {
707
728
  const { note = '', commit = true } = opts;
708
729
  const m = await begin(projectDir, id, 'unblock');
709
730
  if (m.item.data.status === 'parked') dropKnownIssue(m, id);
@@ -725,7 +746,7 @@ export async function unblockItem(projectDir, id, opts = {}) {
725
746
  /** Any stored status → graveyard; an unplayed item leaves as a kill does.
726
747
  * @param {MoveOpts} [opts]
727
748
  */
728
- export async function dropItem(projectDir, id, opts = {}) {
749
+ async function dropItemUnlocked(projectDir, id, opts = {}) {
729
750
  const { reason, tags = [], remove, commit = true } = opts;
730
751
  const m = await begin(projectDir, id, 'drop');
731
752
  if (m.item.data.type === 'feature' && (m.item.data.children ?? []).some((c) => m.ledger.item(c)))
@@ -855,7 +876,7 @@ function newItemText(spec, gate, since) {
855
876
  * Rank items in the given order: `priority` 1, 2, 3… (lower runs sooner). One commit
856
877
  * `gcf(priority)`. A board sends a column's new order after a drag; the console names the ids.
857
878
  */
858
- export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
879
+ async function prioritizeItemsUnlocked(projectDir, ids, { commit = true } = {}) {
859
880
  if (!ids.length) throw new Error('name the items in the order they should run');
860
881
  if (new Set(ids).size !== ids.length) throw new Error('an id is named twice');
861
882
  const ledger = await buildLedger(projectDir);
@@ -896,7 +917,7 @@ export async function prioritizeItems(projectDir, ids, { commit = true } = {}) {
896
917
  * A new work item in ready (or blocked), no commit. A design unit enters work through `take`,
897
918
  * not here: its body is the unit's slots. `from` erases the inbox entry it came from.
898
919
  */
899
- export async function newItem(projectDir, spec) {
920
+ async function newItemUnlocked(projectDir, spec) {
900
921
  const root = gcfOf(projectDir);
901
922
  const ledger = await buildLedger(projectDir);
902
923
  const gate = checkNew(ledger, spec);
@@ -930,3 +951,41 @@ export async function newItem(projectDir, spec) {
930
951
 
931
952
  return { item: `gcf/work/${spec.id}.md`, hash };
932
953
  }
954
+
955
+ /**
956
+ * A committed move holds the main lock from its first read to its commit (Methodology §7): one
957
+ * that read the ledger before taking it would write over a move committed meanwhile.
958
+ * @template T
959
+ * @param {string} projectDir
960
+ * @param {boolean} commit
961
+ * @param {() => Promise<T>} move
962
+ */
963
+ const locked = (projectDir, commit, move) => (commit ? withMainLock(projectDir, move) : move());
964
+
965
+ /** @type {typeof submitItemUnlocked} */
966
+ export const submitItem = (projectDir, id, opts = {}) =>
967
+ locked(projectDir, opts.commit ?? true, () => submitItemUnlocked(projectDir, id, opts));
968
+ /** @type {typeof closeItemUnlocked} */
969
+ export const closeItem = (projectDir, id, opts = {}) =>
970
+ locked(projectDir, opts.commit ?? true, () => closeItemUnlocked(projectDir, id, opts));
971
+ /** @type {typeof verdictItemUnlocked} */
972
+ export const verdictItem = (projectDir, id, verdict, opts = {}) =>
973
+ locked(projectDir, opts.commit ?? true, () => verdictItemUnlocked(projectDir, id, verdict, opts));
974
+ /** @type {typeof decideItemUnlocked} */
975
+ export const decideItem = (projectDir, id, choice, opts = {}) =>
976
+ locked(projectDir, opts.commit ?? true, () => decideItemUnlocked(projectDir, id, choice, opts));
977
+ /** @type {typeof blockItemUnlocked} */
978
+ export const blockItem = (projectDir, id, opts = {}) =>
979
+ locked(projectDir, opts.commit ?? true, () => blockItemUnlocked(projectDir, id, opts));
980
+ /** @type {typeof unblockItemUnlocked} */
981
+ export const unblockItem = (projectDir, id, opts = {}) =>
982
+ locked(projectDir, opts.commit ?? true, () => unblockItemUnlocked(projectDir, id, opts));
983
+ /** @type {typeof dropItemUnlocked} */
984
+ export const dropItem = (projectDir, id, opts = {}) =>
985
+ locked(projectDir, opts.commit ?? true, () => dropItemUnlocked(projectDir, id, opts));
986
+ /** @type {typeof prioritizeItemsUnlocked} */
987
+ export const prioritizeItems = (projectDir, ids, opts = {}) =>
988
+ locked(projectDir, opts.commit ?? true, () => prioritizeItemsUnlocked(projectDir, ids, opts));
989
+ /** @type {typeof newItemUnlocked} */
990
+ export const newItem = (projectDir, spec) =>
991
+ 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.2",
3
+ "version": "0.7.4",
4
4
  "description": "Game Context Framework for AI Factory",
5
5
  "type": "module",
6
6
  "license": "MIT",