gcf-aif-ext 0.5.1 → 0.5.3

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,20 @@ 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.5.3
54
+
55
+ - `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.
56
+ - While a harness run drives an item (`.gcf-session/runs/<id>.lock` held by a live process), the core refuses any move on it, `/gcf-run` on main included, and `gcf integrate` leaves its branch unmerged; the audit and the board read it as `running`. A lock left by a run that died is taken over. `gcf board --json` reports WIP as `{count, limit, over}`.
57
+ - The audit reads the `until:` of every D2 record in `open-questions.md`: a fired one is a stale until record, and the assumption goes back to the human.
58
+ - `gcf design check` reads the text of an HTML comment in a unit or an item for forbidden words (M4) and numbers (M5); a `gcf:allow` pragma and a tool's marked region are skipped.
59
+ - An unplayed tune or kill keeps a systems-doc line the doc held before the item's close.
60
+ - `gcf playtest session` groups the checklist by subsystem and warns when the build is not of main: the remote's default branch when the clone knows it, else any branch but a detached HEAD or a `work/` one.
61
+
62
+ ### 0.5.2
63
+
64
+ - Bundles `cnlp-kit` 3.9, so `gcf design check` and the commit gate accept a bullet written in Cyrillic alone.
65
+ - With `cnlp-kit` 3.9 or later, the `AGENTS.md` block between `<!-- gcf:start -->` and `<!-- gcf:end -->` is a marked region that the project's guide profile does not shape: the `custom_sections` entries 0.5.1 asked for are no longer needed. Update the `cnlp-kit` your project runs `npx cnlp check` with.
66
+
53
67
  ### 0.5.1
54
68
 
55
69
  - A provenance record `assumed: q-NNN` must name a record still in `gcf/open-questions.md` (M9). A D2 record lives until its `until:` fires; once the human confirms the value, cite `answer: q-NNN` instead and remove it. After updating, run `ai-factory gcf design check`: every citation of a removed question blocks the commit gate until it is rewritten.
package/commands/gcf.js CHANGED
@@ -166,6 +166,9 @@ function registerInit(gcf, program) {
166
166
  const block = await readFile(path.join(EXT_ROOT, 'templates', 'agents-block.md'), 'utf8');
167
167
  const agents = await (await lib('agents-block')).writeAgentsBlock(process.cwd(), block);
168
168
  if (agents.length) console.log(`agent instructions written: ${agents.join(', ')}`);
169
+ const restamped = await (await lib('work-ops')).restampFrozen(process.cwd());
170
+ if (restamped.length)
171
+ console.log(`frozen: restamped to guard live: too, commit them: ${restamped.join(', ')}`);
169
172
  console.log(
170
173
  'gcf/ ready. Write PILLARS.md through /gcf-init, then import a GDD with /gcf-ingest ' +
171
174
  'or start the design with /gcf-vision.',
@@ -253,17 +256,30 @@ function buildText(b) {
253
256
  return b.skipped ? 'no build step (dod.build: none)' : 'built with dod.build';
254
257
  }
255
258
 
256
- /** The session sheet: the build, then per item what to check and how to get there. */
257
- function sessionText(s) {
259
+ /** The sheet's head: what was built, and why it may not be what main holds. */
260
+ function sessionHead(s) {
258
261
  const build = buildText(s.build);
259
- const lines = [
262
+
263
+ return [
260
264
  s.commit ? `build: ${s.branch} @ ${s.commit} — ${build}` : 'build: no commit to build yet',
261
- // shortcut: the main branch is named main; read the remote default when a project differs.
262
- ...(s.branch !== 'main' ? ['warning: not on main — the session plays main'] : []),
265
+ // Main by the remote's default when known, else any branch but detached or a worktree's.
266
+ ...(s.branch === 'HEAD' || s.branch?.startsWith('work/') || (s.main && s.branch !== s.main)
267
+ ? [`warning: ${s.branch} is not ${s.main ?? 'main'} — the session plays main`]
268
+ : []),
263
269
  ...(s.dirty ? ['warning: uncommitted changes — the build is not the commit'] : []),
264
270
  ];
271
+ }
272
+
273
+ /** The session sheet: the build, then per item what to check and how to get there. */
274
+ function sessionText(s) {
275
+ const lines = sessionHead(s);
265
276
  if (!s.items.length) return [...lines, 'nothing waits for a playtest'].join('\n');
277
+ let group = null;
278
+ // Items come sorted by their first subsystem: one header each, so play goes system by system.
266
279
  for (const c of s.items) {
280
+ const system = c.system[0] ?? '(no subsystem)';
281
+ if (system !== group) lines.push('', `## ${system}`);
282
+ group = system;
267
283
  lines.push(
268
284
  '',
269
285
  `[ ] ${c.id} (${c.status}) [${c.system.join(',')}] ${c.title ?? c.intent ?? ''}`,
@@ -840,7 +856,7 @@ function registerBoard(gcf) {
840
856
  ...Object.entries(x.columns).map(
841
857
  ([s, ids]) => `${s}: ${ids.map((id) => tag(byId.get(id))).join(', ') || '-'}`,
842
858
  ),
843
- `wip: ${x.wip.verdict}/${x.wip.limit}`,
859
+ `wip: ${x.wip.count}/${x.wip.limit}`,
844
860
  `inbox: ${x.inbox.length}, open questions: ${x.questions.length}`,
845
861
  ].join('\n'),
846
862
  );
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.1",
4
+ "version": "0.5.3",
5
5
  "description": "Game Context Framework for AI Factory",
6
6
  "skills": [
7
7
  "skills/gcf-init",
package/lib/audit-core.js CHANGED
@@ -9,6 +9,7 @@ import { designCheck, recordsIn } from './design-check.js';
9
9
  import { buildLedger } from './ledger.js';
10
10
  import { STORED } from './transitions.js';
11
11
  import { openDepends } from './work-ops.js';
12
+ import { runHolder } from './main-lock.js';
12
13
 
13
14
  function git(args, cwd) {
14
15
  // A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
@@ -32,7 +33,7 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
32
33
  * @property {string[]} legacy_cards
33
34
  * @property {{tokens: number, budget: number, over: boolean, count: number, too_many: boolean}
34
35
  * | null} pillars
35
- * @property {Array<{file: string, trigger: string, status: string}>} until
36
+ * @property {Array<{file: string, id?: string, trigger: string, status: string}>} until
36
37
  * @property {Record<string, object[]>} linter
37
38
  * @property {object[]} pragmas
38
39
  * @property {boolean} index_drift
@@ -40,7 +41,8 @@ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
40
41
  * @property {Array<{id: string, commits: number | null, limit: number}>} stale_questions
41
42
  * @property {number} [schema]
42
43
  * @property {Array<{id: string, type: string, gate: string, status: string, system: string[],
43
- * depends: string[], parent: string | null, tune_count: number}>} [items]
44
+ * depends: string[], parent: string | null, tune_count: number, parallel: string,
45
+ * running: boolean}>} [items]
44
46
  * @property {{blocking: number, units: number, ready: number}} [design]
45
47
  */
46
48
 
@@ -70,7 +72,12 @@ export async function audit(projectDir) {
70
72
  },
71
73
  legacy_cards: legacyCards(root),
72
74
  pillars: await pillarsOf(root, config),
73
- until: untilRecords(items, systems, projectDir, ledger),
75
+ until: untilRecords(
76
+ items,
77
+ systems,
78
+ verdictMatchers(projectDir, ledger, items),
79
+ await questionsText(root),
80
+ ),
74
81
  linter: {},
75
82
  pragmas: [],
76
83
  index_drift: await drifted(root),
@@ -89,6 +96,7 @@ export async function audit(projectDir) {
89
96
  parent: c.data.parent ?? null,
90
97
  tune_count: c.data.tune_count ?? 0,
91
98
  parallel: c.data.parallel ?? 'serial',
99
+ running: runHolder(projectDir, c.data.id) !== null,
92
100
  }));
93
101
  const design = await designCheck(projectDir);
94
102
  checkItems(report, items, config, { projectDir, ledger });
@@ -159,7 +167,7 @@ function staleEntry(c, config, projectDir) {
159
167
  }
160
168
 
161
169
  /** WIP counts what waits for the playtest; unplayed items have their own warning (§4). */
162
- function wipOf(items, config) {
170
+ export function wipOf(items, config) {
163
171
  const count = items.filter((c) => c.data.status === 'awaiting-verdict').length;
164
172
 
165
173
  return { count, limit: config.wip_built_limit, over: count > config.wip_built_limit };
@@ -257,9 +265,20 @@ function untilStatus(trigger, fired) {
257
265
  return 'manual';
258
266
  }
259
267
 
260
- /** Until annotations across work items and systems docs. */
261
- function untilRecords(items, systems, projectDir, ledger) {
262
- const fired = verdictMatchers(projectDir, ledger, items);
268
+ /** open-questions.md, or '' without one. */
269
+ const questionsText = async (root) => {
270
+ const file = path.join(root, 'open-questions.md');
271
+
272
+ return existsSync(file) ? readFile(file, 'utf8') : '';
273
+ };
274
+
275
+ const D2_UNTIL_RE = /^\s+until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))/m;
276
+
277
+ /**
278
+ * Until annotations across work items and systems docs, and the `until:` of each D2 record in
279
+ * open-questions.md (`questions` its text): a fired one is an assumption to ask the human again.
280
+ */
281
+ function untilRecords(items, systems, fired, questions) {
263
282
  const docs = [
264
283
  ...items.map((c) => ({ rel: `work/${c.name}`, body: c.body })),
265
284
  ...systems.map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
@@ -271,6 +290,18 @@ function untilRecords(items, systems, projectDir, ledger) {
271
290
  }
272
291
  }
273
292
 
293
+ for (const rec of questions.replace(/\r\n/g, '\n').split(/\n(?=- id:)/)) {
294
+ const id = rec.match(/^- id:\s*(\S+)/)?.[1];
295
+ const trigger = rec.match(D2_UNTIL_RE)?.[1];
296
+ if (!id || !trigger || !/^\s+class:\s*D2\b/m.test(rec)) continue;
297
+ until.push({
298
+ file: 'gcf/open-questions.md',
299
+ id,
300
+ trigger,
301
+ status: untilStatus(trigger, fired),
302
+ });
303
+ }
304
+
274
305
  return until;
275
306
  }
276
307
 
package/lib/board.js CHANGED
@@ -10,6 +10,8 @@ import { listInbox } from './inbox.js';
10
10
  import { phaseStatus } from './phase.js';
11
11
  import { verifiedBranches, worktreeOf } from './integrate.js';
12
12
  import { resolveInside } from './safe-path.js';
13
+ import { wipOf } from './audit-core.js';
14
+ import { runHolder } from './main-lock.js';
13
15
 
14
16
  const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
15
17
 
@@ -86,8 +88,11 @@ export async function board(projectDir) {
86
88
  system: c.data.system ?? [],
87
89
  ...(c.data.severity ? { severity: c.data.severity } : {}),
88
90
  ...(c.data.blocked ? { blocked: c.data.blocked } : {}),
89
- // `running` and `verify` are not stored (§4): a worktree is a run, a trailer a passed DoD.
90
- running: existsSync(worktreeOf(projectDir, id)) && !ready,
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))),
91
96
  verified: ready,
92
97
  plan: await progress(projectDir, c),
93
98
  });
@@ -103,10 +108,7 @@ export async function board(projectDir) {
103
108
  COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
104
109
  ),
105
110
  items,
106
- wip: {
107
- verdict: items.filter((i) => i.status === 'awaiting-verdict').length,
108
- limit: config.wip_built_limit,
109
- },
111
+ wip: wipOf(ledger.items, config),
110
112
  inbox: await listInbox(root),
111
113
  questions: await questions(root),
112
114
  };
@@ -270,6 +270,23 @@ async function bodyProblems(body, type, stage = 'card') {
270
270
  return { profile, issues };
271
271
  }
272
272
 
273
+ /**
274
+ * Text an HTML comment holds in a body: cnlp-kit gives it no block, but an agent reads the raw
275
+ * file, so M4 and M5 read it too.
276
+ */
277
+ function commentValues(body) {
278
+ const { lines, prose, regions } = parseBlocks(body);
279
+ // A tool's region and its markers belong to the tool; a pragma is an instruction, not prose.
280
+ const owned = (n) => regions.some((r) => n >= r.line && n <= r.end);
281
+ const markup = /^\s*<!--\s*(?:gcf:allow\b|[\w-]+:(?:start|end)\s*-->)/;
282
+
283
+ return prose.flatMap((l, i) =>
284
+ l.trim() && !lines[i].trim() && !owned(i + 1) && !markup.test(l)
285
+ ? [{ block: 'comment', value: l.trim(), line: i + 1 }]
286
+ : [],
287
+ );
288
+ }
289
+
273
290
  /** M4 and M5 over the values of a unit. */
274
291
  function valueIssues(values, ctx) {
275
292
  const out = [];
@@ -372,7 +389,8 @@ async function checkUnit(u, ctx, ledger) {
372
389
  const { profile, issues: shape } = await bodyProblems(u.body, u.header.type, 'design');
373
390
  for (const i of shape) add('M1', atBody(i.line), i.message);
374
391
  const values = slotValues(u.body, profile);
375
- for (const [c, line, m] of valueIssues(values, ctx)) add(c, atBody(line), m);
392
+ for (const [c, line, m] of valueIssues([...values, ...commentValues(u.body)], ctx))
393
+ add(c, atBody(line), m);
376
394
  for (const [c, line, m, sev] of provenanceIssues(u, values, ctx)) add(c, atBody(line), m, sev);
377
395
 
378
396
  return { issues, values };
@@ -462,7 +480,8 @@ async function checkItem(item, ctx, ledger) {
462
480
  // An unplayed body is frozen (§5.4): a later glossary word or unit, or a gone question, cannot
463
481
  // be fixed there, only reported until a tune or a kill takes the item back.
464
482
  const frozen = item.data.status === 'unplayed' ? 'report' : 'block';
465
- for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m, frozen);
483
+ for (const [c, line, m] of valueIssues([...values, ...commentValues(item.body)], ctx))
484
+ add(c, line, m, frozen);
466
485
  for (const r of provenanceOf(item.body)) {
467
486
  const problem = recordProblem(r, ctx);
468
487
  // An item's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
package/lib/integrate.js CHANGED
@@ -8,7 +8,7 @@ import { spawnSync } from 'node:child_process';
8
8
  import path from 'node:path';
9
9
  import { buildLedger } from './ledger.js';
10
10
  import { parse } from './frontmatter.js';
11
- import { withMainLock } from './main-lock.js';
11
+ import { withMainLock, runHolder } from './main-lock.js';
12
12
  import { verifyItem, deltaProblems } from './verify.js';
13
13
  import { submitItem, closeItem, blockItem } from './work-ops.js';
14
14
  import { isValidId } from './id.js';
@@ -242,6 +242,9 @@ async function integrateMerged(projectDir, id, item) {
242
242
  }
243
243
 
244
244
  async function integrateOne(projectDir, id) {
245
+ // Its run records the move itself once it ends: merging now would land code with no move.
246
+ const run = runHolder(projectDir, id);
247
+ if (run) return { id, skipped: `${id} is driven by a harness run (pid ${run})` };
245
248
  const item = (await buildLedger(projectDir)).item(id);
246
249
  const ancestor = ['merge-base', '--is-ancestor', branchOf(id), 'HEAD'];
247
250
  if (git(projectDir, ancestor, { check: false }).ok) return integrateMerged(projectDir, id, item);
package/lib/main-lock.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // each other instead of committing over each other. Re-entrant along one call chain: `integrate`
4
4
  // holds it across the merge and the move it records.
5
5
  import { AsyncLocalStorage } from 'node:async_hooks';
6
- import { link, mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
6
+ import { link, mkdir, readFile, stat, unlink, writeFile } from 'node:fs/promises';
7
+ import { readFileSync } from 'node:fs';
7
8
  import { randomUUID } from 'node:crypto';
8
9
  import path from 'node:path';
9
10
 
@@ -24,6 +25,21 @@ const alive = (pid) => {
24
25
  }
25
26
  };
26
27
 
28
+ /**
29
+ * The pid of a live harness run that drives item `id` (`.gcf-session/runs/<id>.lock`), or null.
30
+ * A move on that item from anywhere else would race the run's own record of it.
31
+ */
32
+ export function runHolder(projectDir, id) {
33
+ try {
34
+ const file = path.join(path.resolve(projectDir), '.gcf-session', 'runs', `${id}.lock`);
35
+ const pid = Number(readFileSync(file, 'utf8').split('\n')[0]);
36
+
37
+ return Number.isInteger(pid) && pid > 0 && pid !== process.pid && alive(pid) ? pid : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
27
43
  /** The lock's text when its holder is gone, else null. The file appears whole: see `take`. */
28
44
  async function staleText(file) {
29
45
  try {
@@ -59,20 +75,34 @@ async function take(file, token) {
59
75
  }
60
76
 
61
77
  /**
62
- * Move a dead holder's lock aside. shortcut: two takers that judged the same dead lock may race
63
- * here — the rename lets one of them win, a fresh lock renamed by the loser is put back.
78
+ * Remove a dead holder's lock. Only the taker holding `<lock>.break` may: two takers that judged
79
+ * the same dead lock cannot both clear it, and the second finds the winner's live lock in place.
80
+ * Nothing else replaces the lock meanwhile — `take` fails on an existing file.
64
81
  */
65
82
  async function clearStale(file, text) {
66
- const aside = `${file}.stale-${randomUUID()}`;
67
- try {
68
- await rename(file, aside);
69
- } catch {
83
+ const breaker = `${file}.break`;
84
+ const token = randomUUID();
85
+ if (!(await take(breaker, token))) {
86
+ // A breaker is held for a read and an unlink; an old one belongs to a taker that died there.
87
+ const old = await stat(breaker).then(
88
+ (s) => Date.now() - s.mtimeMs > 10000,
89
+ () => false,
90
+ );
91
+ if (old) await unlink(breaker).catch(() => {});
92
+ await sleep(100);
93
+
70
94
  return;
71
95
  }
72
96
 
73
- if ((await readFile(aside, 'utf8').catch(() => text)) !== text)
74
- await link(aside, file).catch(() => {});
75
- await unlink(aside).catch(() => {});
97
+ try {
98
+ if ((await readFile(file, 'utf8').catch(() => null)) === text) await unlink(file);
99
+ } catch {
100
+ // released meanwhile
101
+ } finally {
102
+ // A breaker taken over as old meanwhile belongs to its new holder.
103
+ if ((await readFile(breaker, 'utf8').catch(() => '')).includes(token))
104
+ await unlink(breaker).catch(() => {});
105
+ }
76
106
  }
77
107
 
78
108
  /**
@@ -64,6 +64,12 @@ export async function playtestSession(
64
64
  const dirty = git(projectDir, ['status', '--porcelain', '--', '.', ':(exclude)gcf/.metrics']);
65
65
  const session = {
66
66
  branch: git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']),
67
+ // The remote's default branch when the clone knows it: main by any name. Null otherwise.
68
+ main:
69
+ git(projectDir, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])?.replace(
70
+ /^origin\//,
71
+ '',
72
+ ) ?? null,
67
73
  commit: git(projectDir, ['rev-parse', '--short', 'HEAD']),
68
74
  // No commit (no repository, or none yet): the build is unknown, not clean.
69
75
  dirty: dirty === null ? null : Boolean(dirty),
@@ -9,11 +9,15 @@ import { scalarIn } from './design.js';
9
9
 
10
10
  const NONE_LINE = '- none: no item of this subsystem has collapsed yet';
11
11
 
12
+ /**
13
+ * The raw lines of a `key:` block. `prose`, not `lines`: cnlp-kit 3.9 blanks HTML comments in
14
+ * `lines`, and upsertBlock rewrites the block from these — a `gcf:allow` pragma must survive.
15
+ */
12
16
  export function blockLines(body, key) {
13
- const { lines, sections } = parseBlocks(body);
17
+ const { prose, sections } = parseBlocks(body);
14
18
  const at = sections.find((s) => s.key === key);
15
19
 
16
- return at ? itemsOf(lines, at.line).map((i) => i.text) : [];
20
+ return at ? itemsOf(prose, at.line).map((i) => i.text) : [];
17
21
  }
18
22
 
19
23
  /**
package/lib/verdict-fs.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // work-ops.js; these are the deterministic pieces they share.
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { existsSync } from 'node:fs';
6
+ import { spawnSync } from 'node:child_process';
6
7
  import path from 'node:path';
7
8
  import { parseBlocks, itemsOf, readRecords } from 'cnlp-kit';
8
9
  import { serialize } from './frontmatter.js';
@@ -82,22 +83,60 @@ export async function mergeIntoSystem(root, system, item) {
82
83
  await atomicWrite(doc.file, serialize({ ...doc.data, updated_by: item.data.id }, body));
83
84
  }
84
85
 
86
+ /**
87
+ * The lines a systems doc held before the item's unplayed close: the merge skipped them, so they
88
+ * are some earlier item's or unit's, not this one's. None when that commit is not found.
89
+ */
90
+ function linesBeforeClose(root, system, id) {
91
+ const cwd = path.dirname(root);
92
+ const close = unplayedCloseCommit(cwd, id);
93
+ if (!close) return [];
94
+ const text = fileAt(cwd, `${close}^`, path.join(root, 'systems', `${system}.md`));
95
+
96
+ return text === null ? [] : text.replace(/\r\n/g, '\n').split('\n');
97
+ }
98
+
99
+ /** The last commit that closed item `id` unplayed, or null. */
100
+ export function unplayedCloseCommit(projectDir, id) {
101
+ const log = spawnSync(
102
+ 'git',
103
+ ['log', '-1', '--format=%H', '--fixed-strings', `--grep=gcf(close): ${id} → unplayed`],
104
+ { cwd: projectDir, encoding: 'utf8' },
105
+ );
106
+
107
+ return (log.status === 0 && log.stdout.trim()) || null;
108
+ }
109
+
110
+ /** A file's text at a revision, or null when the revision does not hold it. */
111
+ export function fileAt(projectDir, rev, file) {
112
+ const rel = path.relative(projectDir, file).replace(/\\/g, '/');
113
+ const show = spawnSync('git', ['show', `${rev}:${rel}`], {
114
+ cwd: projectDir,
115
+ encoding: 'utf8',
116
+ maxBuffer: 64 * 1024 * 1024,
117
+ });
118
+ if (show.error) throw show.error;
119
+
120
+ return show.status === 0 ? show.stdout : null;
121
+ }
122
+
85
123
  /**
86
124
  * Take an unplayed item's lines back out of a systems doc (tune, kill or drop of an unplayed
87
- * item). `keep` are lines other unplayed items still owe the doc. shortcut: a line shared word
88
- * for word with an already kept item leaves too — that body is gone; upgrade to per-line origin
89
- * marks if that ever bites.
125
+ * item). `keep` are lines other unplayed items still owe the doc; a line the doc held before the
126
+ * close stays too. shortcut: a line an item kept after this close brought word for word was
127
+ * skipped by its merge and leaves here; upgrade to per-line origin marks if that ever bites.
90
128
  */
91
129
  export async function unmergeFromSystem(root, system, item, keep = []) {
92
130
  const file = path.join(root, 'systems', `${system}.md`);
93
131
  if (!existsSync(file)) return;
94
132
  const doc = await openSystemDoc(root, system);
133
+ const before = linesBeforeClose(root, system, item.data.id);
95
134
  let body = doc.body;
96
135
  for (const [section, lines] of Object.entries(collapseLines(item.body))) {
97
136
  body = removeLines(
98
137
  body,
99
138
  section,
100
- lines.filter((l) => !keep.includes(l)),
139
+ lines.filter((l) => !keep.includes(l) && !before.includes(l)),
101
140
  );
102
141
  }
103
142
 
package/lib/work-ops.js CHANGED
@@ -8,7 +8,7 @@ import { existsSync } from 'node:fs';
8
8
  import { spawnSync } from 'node:child_process';
9
9
  import { createHash } from 'node:crypto';
10
10
  import path from 'node:path';
11
- import { serialize } from './frontmatter.js';
11
+ import { parse, serialize } from './frontmatter.js';
12
12
  import { buildLedger } from './ledger.js';
13
13
  import { readConfig } from './gcf-paths.js';
14
14
  import { atomicWrite, withFileRollback } from './safe-path.js';
@@ -25,6 +25,8 @@ import {
25
25
  systemFiles,
26
26
  buryLine,
27
27
  recordLine,
28
+ unplayedCloseCommit,
29
+ fileAt,
28
30
  } from './verdict-fs.js';
29
31
  import {
30
32
  readLiveStrict,
@@ -36,6 +38,7 @@ import {
36
38
  } from './live.js';
37
39
  import { dropInbox } from './inbox.js';
38
40
  import { refuseByPhase } from './phase.js';
41
+ import { runHolder } from './main-lock.js';
39
42
 
40
43
  /**
41
44
  * Options of a committed move; each move reads the ones it needs.
@@ -108,15 +111,44 @@ export const frozenHash = (item) =>
108
111
  .update(`\nlive: ${JSON.stringify(item.data.live ?? null)}`)
109
112
  .digest('hex');
110
113
 
114
+ /** The 0.4.0 `frozen:`: the sha1 of the body alone, without the `live:` delta. */
115
+ const legacyFrozen = (item) =>
116
+ createHash('sha1').update(String(item.body).replace(/\r\n/g, '\n').trim()).digest('hex');
117
+
111
118
  /**
112
- * Whether an unplayed item still matches its `frozen:`. An item closed by 0.4.0 holds the sha1
113
- * of its body alone: it still guards the body. shortcut: such an item's live: stays unguarded
114
- * until it is closed again; drop the legacy form once no 0.4.0 unplayed item is left.
119
+ * Whether an unplayed item still matches its `frozen:`. An item closed by 0.4.0 holds the
120
+ * legacy form, which guards the body only, until `gcf init` restamps it (restampFrozen).
115
121
  */
116
122
  export const matchesFrozen = (item) =>
117
- item.data.frozen === frozenHash(item) ||
118
- item.data.frozen ===
119
- createHash('sha1').update(String(item.body).replace(/\r\n/g, '\n').trim()).digest('hex');
123
+ item.data.frozen === frozenHash(item) || item.data.frozen === legacyFrozen(item);
124
+
125
+ /** Whether the item's `live:` is the one its unplayed close committed. */
126
+ function liveAsClosed(projectDir, item) {
127
+ const close = unplayedCloseCommit(projectDir, item.data.id);
128
+ const text = close && fileAt(projectDir, close, item.file);
129
+ if (!text) return false;
130
+ const live = (data) => JSON.stringify(data.live ?? null);
131
+
132
+ return live(parse(text).data) === live(item.data);
133
+ }
134
+
135
+ /**
136
+ * Give every unplayed item still frozen in the 0.4.0 form the full hash, so its `live:` is
137
+ * guarded too — only while that `live:` is still the one its close committed: a later edit is
138
+ * the drift the guard exists for, and must not be stamped in. Returns the ids.
139
+ */
140
+ export async function restampFrozen(projectDir) {
141
+ const ledger = await buildLedger(projectDir);
142
+ const legacy = ledger.items.filter(
143
+ (i) =>
144
+ i.data.status === 'unplayed' &&
145
+ i.data.frozen === legacyFrozen(i) &&
146
+ liveAsClosed(projectDir, i),
147
+ );
148
+ for (const i of legacy) await writeItem(i, { ...i.data, frozen: frozenHash(i) });
149
+
150
+ return legacy.map((i) => String(i.data.id));
151
+ }
120
152
 
121
153
  /** Children of a feature that are not finished: live in any status but unplayed and parked. */
122
154
  function unfinishedChildren(ledger, feature) {
@@ -136,6 +168,12 @@ async function begin(projectDir, id, move) {
136
168
  const ledger = await buildLedger(projectDir);
137
169
  const item = itemOf(ledger, id);
138
170
  need(move, item);
171
+ const run = runHolder(projectDir, id);
172
+ if (run)
173
+ throw new Error(
174
+ `${id} is driven by a harness run (pid ${run}): wait until that run ends; if no harness ` +
175
+ `run is going, delete .gcf-session/runs/${id}.lock`,
176
+ );
139
177
  const files = [item.file];
140
178
  const steps = [];
141
179
  const liveOps = [];