gcf-aif-ext 0.5.2 → 0.6.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 +17 -0
- package/commands/gcf.js +84 -18
- package/extension.json +1 -1
- package/lib/audit-core.js +38 -7
- package/lib/board.js +115 -33
- package/lib/design-check.js +21 -2
- package/lib/integrate.js +7 -4
- package/lib/live.js +6 -2
- package/lib/main-lock.js +40 -10
- package/lib/playtest-list.js +7 -1
- package/lib/transitions.js +25 -0
- package/lib/verdict-fs.js +43 -4
- package/lib/work-ops.js +77 -20
- package/package.json +1 -1
- package/skills/gcf-audit/SKILL.md +5 -4
- package/skills/gcf-init/SKILL.md +1 -1
- package/templates/agents-block.md +2 -1
package/README.md
CHANGED
|
@@ -50,6 +50,23 @@ Updating preserves `gcf/`, `cnlp.config.json` and the pre-commit hook. Run `ai-f
|
|
|
50
50
|
|
|
51
51
|
On Windows, run `extension add` and `extension update` from PowerShell or cmd, not Git Bash: the installer unpacks with `tar`, and Git Bash's GNU tar reads `C:\…` as a remote host (`Cannot connect to C: resolve failed`). The Windows `tar.exe` handles it.
|
|
52
52
|
|
|
53
|
+
### 0.6.0
|
|
54
|
+
|
|
55
|
+
- `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`.
|
|
56
|
+
- `item new --model <m> --effort <e>`: the run of the item uses them (gcf-harness-aif-ext 0.7). One word each.
|
|
57
|
+
- `playtest session --json` and `live check --json` answer JSON: the flag went to the parent command, which printed text.
|
|
58
|
+
- `--commit` on `item new`, `inbox add` and `inbox drop` records the move alone (`gcf(item): <id>`, `gcf(inbox): …`) for a caller with no console, a board. Without it nothing is committed, as before. Inbox lines added earlier without a commit go into the same commit: the inbox is raw input.
|
|
59
|
+
- `inbox add --text=<text>`: a text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of ai-factory's commands, `--` included.
|
|
60
|
+
|
|
61
|
+
### 0.5.3
|
|
62
|
+
|
|
63
|
+
- `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.
|
|
64
|
+
- 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}`.
|
|
65
|
+
- 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.
|
|
66
|
+
- `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.
|
|
67
|
+
- An unplayed tune or kill keeps a systems-doc line the doc held before the item's close.
|
|
68
|
+
- `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.
|
|
69
|
+
|
|
53
70
|
### 0.5.2
|
|
54
71
|
|
|
55
72
|
- Bundles `cnlp-kit` 3.9, so `gcf design check` and the commit gate accept a bullet written in Cyrillic alone.
|
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
|
|
257
|
-
function
|
|
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
|
-
|
|
262
|
+
|
|
263
|
+
return [
|
|
260
264
|
s.commit ? `build: ${s.branch} @ ${s.commit} — ${build}` : 'build: no commit to build yet',
|
|
261
|
-
//
|
|
262
|
-
...(s.branch
|
|
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 ?? ''}`,
|
|
@@ -305,7 +321,8 @@ function registerPlaytest(gcf) {
|
|
|
305
321
|
const r = await (
|
|
306
322
|
await lib('playtest-list')
|
|
307
323
|
).playtestSession(process.cwd(), { build: Boolean(o.build) });
|
|
308
|
-
|
|
324
|
+
// Commander gives a flag both levels define to the parent: `playtest session --json`.
|
|
325
|
+
out(o.json || playtest.opts().json, r, sessionText);
|
|
309
326
|
if (r.build && !r.build.ok) process.exitCode = 1;
|
|
310
327
|
});
|
|
311
328
|
}
|
|
@@ -627,6 +644,9 @@ function registerItem(gcf) {
|
|
|
627
644
|
.option('--milestone <name>', 'the milestone of game.md it belongs to')
|
|
628
645
|
.option('--release <version>', 'a release: the version it ships')
|
|
629
646
|
.option('--channel <name>', 'a release: where it ships, e.g. steam')
|
|
647
|
+
.option('--model <name>', 'the model its run uses, e.g. opus')
|
|
648
|
+
.option('--effort <level>', 'the effort its run uses, e.g. high')
|
|
649
|
+
.option('--commit', 'commit it (gcf(item)): for a caller with no console, e.g. a board')
|
|
630
650
|
.action(async (id, o) => {
|
|
631
651
|
const { newItem } = await lib('work-ops');
|
|
632
652
|
const r = await newItem(process.cwd(), {
|
|
@@ -644,8 +664,11 @@ function registerItem(gcf) {
|
|
|
644
664
|
milestone: o.milestone,
|
|
645
665
|
version: o.release,
|
|
646
666
|
channel: o.channel,
|
|
667
|
+
model: o.model,
|
|
668
|
+
effort: o.effort,
|
|
669
|
+
commit: o.commit === true,
|
|
647
670
|
});
|
|
648
|
-
console.log(r.item);
|
|
671
|
+
console.log(`${r.item}${shortHash(r.hash)}`);
|
|
649
672
|
});
|
|
650
673
|
}
|
|
651
674
|
|
|
@@ -720,6 +743,36 @@ function registerDecide(gcf) {
|
|
|
720
743
|
});
|
|
721
744
|
}
|
|
722
745
|
|
|
746
|
+
/**
|
|
747
|
+
* An inbox edit; with `commit`, committed alone under the main lock — a board has no console to
|
|
748
|
+
* commit from. Returns the commit hash, or null.
|
|
749
|
+
* @param {boolean | undefined} commit
|
|
750
|
+
* @param {string} message
|
|
751
|
+
* @param {(root: string) => Promise<unknown>} edit
|
|
752
|
+
*/
|
|
753
|
+
async function inboxMove(commit, message, edit) {
|
|
754
|
+
const { gcfRoot } = await lib('gcf-paths');
|
|
755
|
+
const root = gcfRoot();
|
|
756
|
+
if (!commit) {
|
|
757
|
+
await edit(root);
|
|
758
|
+
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
const { commitMove } = await lib('unit-ops');
|
|
763
|
+
const inbox = path.join(root, 'inbox.md');
|
|
764
|
+
|
|
765
|
+
// Raw input (merge=union): lines added before without a commit ride along with this one.
|
|
766
|
+
return commitMove(process.cwd(), message, {
|
|
767
|
+
files: [inbox],
|
|
768
|
+
inputs: [inbox],
|
|
769
|
+
mutate: async () => {
|
|
770
|
+
await edit(root);
|
|
771
|
+
},
|
|
772
|
+
commit: true,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
723
776
|
function registerInbox(gcf) {
|
|
724
777
|
const inbox = gcf
|
|
725
778
|
.command('inbox')
|
|
@@ -733,21 +786,34 @@ function registerInbox(gcf) {
|
|
|
733
786
|
);
|
|
734
787
|
});
|
|
735
788
|
inbox
|
|
736
|
-
.command('add
|
|
789
|
+
.command('add [text]')
|
|
737
790
|
.description('Append an entry')
|
|
738
791
|
.option('--source <source>', 'playtest | ci | agent | operator', 'operator')
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
792
|
+
// A text that starts with a hyphen ("-5 fps on level 3") reads as a flag at every level of
|
|
793
|
+
// ai-factory's commands, `--` included: as `--text=<text>` it stays one value.
|
|
794
|
+
.option('--text <text>', 'the entry, when it starts with a hyphen')
|
|
795
|
+
.option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
|
|
796
|
+
.action(async (arg, o) => {
|
|
797
|
+
const text = o.text ?? arg;
|
|
798
|
+
if (!text || (o.text !== undefined && arg !== undefined))
|
|
799
|
+
throw new Error('give the entry once: as the argument, or as --text=<text>');
|
|
800
|
+
const { addInbox } = await lib('inbox');
|
|
801
|
+
let id = '';
|
|
802
|
+
const hash = await inboxMove(o.commit, 'gcf(inbox): add', async (root) => {
|
|
803
|
+
id = (await addInbox(root, text, o.source)).id;
|
|
804
|
+
});
|
|
805
|
+
console.log(`${id}${shortHash(hash)}`);
|
|
743
806
|
});
|
|
744
807
|
inbox
|
|
745
808
|
.command('drop <in-id>')
|
|
746
809
|
.description('Erase an entry: triaged as noise, a duplicate, or turned into an item')
|
|
747
|
-
.
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
810
|
+
.option('--commit', 'commit it (gcf(inbox)): for a caller with no console, e.g. a board')
|
|
811
|
+
.action(async (id, o) => {
|
|
812
|
+
const { dropInbox } = await lib('inbox');
|
|
813
|
+
const hash = await inboxMove(o.commit, `gcf(inbox): drop ${id}`, (root) =>
|
|
814
|
+
dropInbox(root, id),
|
|
815
|
+
);
|
|
816
|
+
console.log(`erased ${id}${shortHash(hash)}`);
|
|
751
817
|
});
|
|
752
818
|
}
|
|
753
819
|
|
|
@@ -778,7 +844,7 @@ function registerLive(gcf) {
|
|
|
778
844
|
.option('--json', 'machine output')
|
|
779
845
|
.action(async (o) => {
|
|
780
846
|
const r = await (await lib('live')).checkLive(process.cwd());
|
|
781
|
-
out(o.json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
|
|
847
|
+
out(o.json || live.opts().json, r.issues, (l) => (l.length ? l.join('\n') : 'clean'));
|
|
782
848
|
if (r.issues.length) process.exitCode = 1;
|
|
783
849
|
});
|
|
784
850
|
}
|
|
@@ -840,7 +906,7 @@ function registerBoard(gcf) {
|
|
|
840
906
|
...Object.entries(x.columns).map(
|
|
841
907
|
([s, ids]) => `${s}: ${ids.map((id) => tag(byId.get(id))).join(', ') || '-'}`,
|
|
842
908
|
),
|
|
843
|
-
`wip: ${x.wip.
|
|
909
|
+
`wip: ${x.wip.count}/${x.wip.limit}`,
|
|
844
910
|
`inbox: ${x.inbox.length}, open questions: ${x.questions.length}`,
|
|
845
911
|
].join('\n'),
|
|
846
912
|
);
|
package/extension.json
CHANGED
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
|
|
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(
|
|
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
|
-
/**
|
|
261
|
-
|
|
262
|
-
const
|
|
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,29 +10,82 @@ 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';
|
|
15
|
+
import { ITEM_TYPES } from './validate-item.js';
|
|
16
|
+
import { movesOf } from './transitions.js';
|
|
17
|
+
|
|
18
|
+
// The version of the JSON below (Methodology §11): a board refuses one it does not know. Bump it
|
|
19
|
+
// when a field changes meaning or goes away; a new field does not bump it.
|
|
20
|
+
export const BOARD_SCHEMA = 1;
|
|
13
21
|
|
|
14
22
|
const COLUMNS = ['ready', 'blocked', 'awaiting-verdict', 'awaiting-decision', 'unplayed', 'parked'];
|
|
15
23
|
|
|
16
24
|
/**
|
|
17
|
-
* Plan progress `{tasks, done}` from its checkboxes
|
|
18
|
-
* outside the project or not a file reads as `{error}`, the
|
|
25
|
+
* Plan progress `{tasks, done}` from its checkboxes and the plan's project path, both null
|
|
26
|
+
* without a plan file (§3.3); a plan: outside the project or not a file reads as `{error}`, the
|
|
27
|
+
* rest of the board still shows.
|
|
19
28
|
*/
|
|
20
29
|
async function progress(projectDir, item) {
|
|
30
|
+
const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
21
31
|
try {
|
|
22
|
-
const plan = item.data.plan ?? `.ai-factory/plans/${item.data.id}.md`;
|
|
23
32
|
const file = resolveInside(projectDir, plan);
|
|
24
|
-
if (!existsSync(file)) return null;
|
|
33
|
+
if (!existsSync(file)) return { plan: null, plan_file: null };
|
|
25
34
|
const text = await readFile(file, 'utf8');
|
|
26
35
|
|
|
27
36
|
return {
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
plan: {
|
|
38
|
+
tasks: (text.match(/^\s*- \[[ xX]\] /gm) ?? []).length,
|
|
39
|
+
done: (text.match(/^\s*- \[[xX]\] /gm) ?? []).length,
|
|
40
|
+
},
|
|
41
|
+
plan_file: plan,
|
|
30
42
|
};
|
|
31
43
|
} catch (e) {
|
|
32
|
-
return { error: e.message };
|
|
44
|
+
return { plan: { error: e.message }, plan_file: null };
|
|
33
45
|
}
|
|
34
46
|
}
|
|
35
47
|
|
|
48
|
+
/** The lines `- <a> | <b> | …` of a gcf record file, split on the pipes. */
|
|
49
|
+
async function records(root, name) {
|
|
50
|
+
const file = path.join(root, name);
|
|
51
|
+
if (!existsSync(file)) return [];
|
|
52
|
+
|
|
53
|
+
return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
|
|
54
|
+
const m = l.match(/^- (\S+)\s*\|(.*)$/);
|
|
55
|
+
|
|
56
|
+
return m ? [[m[1], ...m[2].split('|').map((f) => f.trim())]] : [];
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** history.md: `- <id> | <event> | <date>` (§9). */
|
|
61
|
+
const historyOf = async (root) =>
|
|
62
|
+
(await records(root, 'history.md')).map(([id, event, date]) => ({
|
|
63
|
+
id,
|
|
64
|
+
event: event || null,
|
|
65
|
+
date: date || null,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* graveyard.md: `- <id> | <type> | <reason> | tags: <tags>`; a 0.3 line has no type, and its
|
|
70
|
+
* second field is a type only when it names one (§9).
|
|
71
|
+
*/
|
|
72
|
+
const graveyardOf = async (root) =>
|
|
73
|
+
(await records(root, 'graveyard.md')).map(([id, ...fields]) => {
|
|
74
|
+
const tags = fields.at(-1)?.startsWith('tags:') ? fields.pop() : null;
|
|
75
|
+
const typed = fields.length > 1 && ITEM_TYPES.includes(fields[0]);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
id,
|
|
79
|
+
type: typed ? fields[0] : null,
|
|
80
|
+
reason: (typed ? fields.slice(1) : fields).join(' | ') || null,
|
|
81
|
+
tags: (tags ?? '')
|
|
82
|
+
.slice('tags:'.length)
|
|
83
|
+
.split(',')
|
|
84
|
+
.map((t) => t.trim())
|
|
85
|
+
.filter(Boolean),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
36
89
|
/** Verified branches; none outside a repository. */
|
|
37
90
|
function verifiedOf(projectDir) {
|
|
38
91
|
try {
|
|
@@ -66,48 +119,77 @@ async function phaseOf(projectDir) {
|
|
|
66
119
|
}
|
|
67
120
|
}
|
|
68
121
|
|
|
122
|
+
/** The release block with every field: one a malformed block lacks is null (live check says so). */
|
|
123
|
+
const releaseOf = (r) =>
|
|
124
|
+
r && {
|
|
125
|
+
version: r.version,
|
|
126
|
+
...Object.fromEntries(['channel', 'date', 'phase'].map((k) => [k, r[k] ?? null])),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The run log of an item, project-relative, or null before its first run. */
|
|
130
|
+
function logOf(projectDir, id) {
|
|
131
|
+
const log = `.gcf-session/runs/${id}.log`;
|
|
132
|
+
|
|
133
|
+
return existsSync(path.join(projectDir, log)) ? log : null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 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'];
|
|
138
|
+
const LISTS = ['children', 'system', 'depends'];
|
|
139
|
+
|
|
140
|
+
/** One item of the board. */
|
|
141
|
+
async function itemOf(projectDir, c, verified) {
|
|
142
|
+
const id = String(c.data.id);
|
|
143
|
+
// A trailer counts only while the item waits on main for its integrate.
|
|
144
|
+
const ready = c.data.status === 'ready' && verified.has(id);
|
|
145
|
+
// `running` and `verify` are not stored (§4): a live harness run, or a worktree of a ready
|
|
146
|
+
// item, is a run; a trailer is a passed DoD.
|
|
147
|
+
const running =
|
|
148
|
+
runHolder(projectDir, id) !== null ||
|
|
149
|
+
(c.data.status === 'ready' && !ready && existsSync(worktreeOf(projectDir, id)));
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
id,
|
|
153
|
+
type: c.data.type,
|
|
154
|
+
gate: c.data.gate,
|
|
155
|
+
status: c.data.status,
|
|
156
|
+
...Object.fromEntries(SCALARS.map((k) => [k, c.data[k] ?? null])),
|
|
157
|
+
...Object.fromEntries(LISTS.map((k) => [k, c.data[k] ?? []])),
|
|
158
|
+
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
159
|
+
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
160
|
+
running,
|
|
161
|
+
verified: ready,
|
|
162
|
+
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
163
|
+
moves: running ? [] : movesOf(c.data),
|
|
164
|
+
file: `gcf/work/${c.name}`,
|
|
165
|
+
log: logOf(projectDir, id),
|
|
166
|
+
...(await progress(projectDir, c)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
69
170
|
export async function board(projectDir) {
|
|
70
171
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
71
172
|
const ledger = await buildLedger(projectDir);
|
|
72
173
|
const verified = verifiedOf(projectDir);
|
|
73
174
|
const items = [];
|
|
74
|
-
for (const c of ledger.items)
|
|
75
|
-
const id = String(c.data.id);
|
|
76
|
-
// A trailer counts only while the item waits on main for its integrate.
|
|
77
|
-
const ready = c.data.status === 'ready' && verified.has(id);
|
|
78
|
-
items.push({
|
|
79
|
-
id,
|
|
80
|
-
type: c.data.type,
|
|
81
|
-
gate: c.data.gate,
|
|
82
|
-
status: c.data.status,
|
|
83
|
-
title: c.data.title ?? null,
|
|
84
|
-
parent: c.data.parent ?? null,
|
|
85
|
-
children: c.data.children ?? [],
|
|
86
|
-
system: c.data.system ?? [],
|
|
87
|
-
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
88
|
-
...(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
|
-
verified: ready,
|
|
92
|
-
plan: await progress(projectDir, c),
|
|
93
|
-
});
|
|
94
|
-
}
|
|
175
|
+
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
95
176
|
|
|
96
177
|
const config = await readConfig(root);
|
|
97
178
|
const live = await readLive(root);
|
|
98
179
|
|
|
99
180
|
return {
|
|
181
|
+
schema: BOARD_SCHEMA,
|
|
100
182
|
phase: await phaseOf(projectDir),
|
|
101
|
-
|
|
183
|
+
// A malformed block reads with fields missing (live check names it): null, not absent.
|
|
184
|
+
release: releaseOf(live.release),
|
|
102
185
|
columns: Object.fromEntries(
|
|
103
186
|
COLUMNS.map((s) => [s, items.filter((i) => i.status === s).map((i) => i.id)]),
|
|
104
187
|
),
|
|
105
188
|
items,
|
|
106
|
-
wip:
|
|
107
|
-
verdict: items.filter((i) => i.status === 'awaiting-verdict').length,
|
|
108
|
-
limit: config.wip_built_limit,
|
|
109
|
-
},
|
|
189
|
+
wip: wipOf(ledger.items, config),
|
|
110
190
|
inbox: await listInbox(root),
|
|
111
191
|
questions: await questions(root),
|
|
192
|
+
history: await historyOf(root),
|
|
193
|
+
graveyard: await graveyardOf(root),
|
|
112
194
|
};
|
|
113
195
|
}
|
package/lib/design-check.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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';
|
|
@@ -238,16 +238,19 @@ async function integrateMerged(projectDir, id, item) {
|
|
|
238
238
|
if (!item || item.data.status !== 'ready' || movedSinceMerge(projectDir, id))
|
|
239
239
|
return { id, skipped: `${branchOf(id)} is in main already`, ...cleanUp(projectDir, id) };
|
|
240
240
|
|
|
241
|
-
return {
|
|
241
|
+
return { ...(await recordMove(projectDir, item)), id, ...cleanUp(projectDir, id) };
|
|
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);
|
|
248
251
|
if (!item || item.data.status !== 'ready')
|
|
249
252
|
return { id, skipped: `${id} is ${item ? item.data.status : 'not a work item'} on main` };
|
|
250
|
-
if (item.data.type === 'spike') return {
|
|
253
|
+
if (item.data.type === 'spike') return { ...(await integrateSpike(projectDir, item)), id };
|
|
251
254
|
const foreign = foreignGcfEdits(projectDir, id);
|
|
252
255
|
if (foreign.length)
|
|
253
256
|
return {
|
|
@@ -260,7 +263,7 @@ async function integrateOne(projectDir, id) {
|
|
|
260
263
|
if (failed) return { id, ...failed };
|
|
261
264
|
const moved = await recordMove(projectDir, (await buildLedger(projectDir)).item(id));
|
|
262
265
|
|
|
263
|
-
return {
|
|
266
|
+
return { ...moved, id, ...cleanUp(projectDir, id) };
|
|
264
267
|
}
|
|
265
268
|
|
|
266
269
|
/**
|
package/lib/live.js
CHANGED
|
@@ -66,7 +66,7 @@ export function parseLive(text) {
|
|
|
66
66
|
/** A record line of capabilities or known-issues; false when the line is not one. */
|
|
67
67
|
const recordLine = (line) => {
|
|
68
68
|
const start = line.match(/^- id:\s*(\S+)\s*$/);
|
|
69
|
-
if (start && section) {
|
|
69
|
+
if (start && section && section !== 'release') {
|
|
70
70
|
rec = { id: start[1], summary: '' };
|
|
71
71
|
out[section].push(rec);
|
|
72
72
|
|
|
@@ -106,7 +106,11 @@ const recordText = (r) =>
|
|
|
106
106
|
|
|
107
107
|
const byId = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
108
108
|
|
|
109
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* LIVE.md text: records sorted by id, so two branches touching different ids merge cleanly.
|
|
111
|
+
* @param {{phase?: string | null, release?: LiveRelease | null, capabilities: LiveRecord[],
|
|
112
|
+
* knownIssues: LiveRecord[]}} live
|
|
113
|
+
*/
|
|
110
114
|
export function serializeLive({ phase = null, release = null, capabilities, knownIssues }) {
|
|
111
115
|
const block = (key, list) =>
|
|
112
116
|
`${key}:\n${list.length ? [...list].sort(byId).map(recordText).join('\n') : NONE[key]}\n`;
|
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,
|
|
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
|
-
*
|
|
63
|
-
*
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
await
|
|
75
|
-
|
|
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
|
/**
|
package/lib/playtest-list.js
CHANGED
|
@@ -64,10 +64,16 @@ 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),
|
|
70
|
-
build: null,
|
|
76
|
+
build: /** @type {ReturnType<typeof runDod> | null} */ (null),
|
|
71
77
|
items,
|
|
72
78
|
};
|
|
73
79
|
if (build) session.build = runDod(projectDir, await readConfig(root), 'build', stdio);
|
package/lib/transitions.js
CHANGED
|
@@ -37,6 +37,31 @@ export function allowedFrom(move, status) {
|
|
|
37
37
|
/** Statuses `move` starts from, for error messages. */
|
|
38
38
|
export const fromOf = (move) => FROM[move] ?? [];
|
|
39
39
|
|
|
40
|
+
export const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
41
|
+
const BUG_ONLY = ['wontfix', 'known'];
|
|
42
|
+
|
|
43
|
+
/** Whether a decision fits an item's type: a feature has its own, wontfix and known a bug's. */
|
|
44
|
+
export const decisionFits = (choice, type) =>
|
|
45
|
+
FEATURE_ONLY.includes(choice) === (type === 'feature') &&
|
|
46
|
+
(!BUG_ONLY.includes(choice) || type === 'bug');
|
|
47
|
+
|
|
48
|
+
/** Whether a move fits the item beyond its status: its gate for submit and close, its type. */
|
|
49
|
+
function fits(move, { type, gate }) {
|
|
50
|
+
const [cmd, arg] = move.split(':');
|
|
51
|
+
if (cmd === 'submit') return gate !== 'auto';
|
|
52
|
+
if (cmd === 'close') return gate === 'auto';
|
|
53
|
+
|
|
54
|
+
return cmd !== 'decide' || decisionFits(arg, type);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The moves an item may take now (§4.1), named as `allowedFrom` names them: `verdict:keep`,
|
|
59
|
+
* `decide:go`, `unblock`… A board offers these and nothing else; each command still checks the
|
|
60
|
+
* rest itself (dependencies, a live: delta, the tune limit).
|
|
61
|
+
*/
|
|
62
|
+
export const movesOf = (data) =>
|
|
63
|
+
Object.keys(FROM).filter((m) => allowedFrom(m, data.status) && fits(m, data));
|
|
64
|
+
|
|
40
65
|
/** A status an item FILE may carry. */
|
|
41
66
|
export function isStorable(status) {
|
|
42
67
|
return STORED.includes(status);
|
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
|
|
88
|
-
*
|
|
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,12 +8,11 @@ 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
|
-
import { atomicWrite
|
|
15
|
-
import {
|
|
16
|
-
import { allowedFrom, fromOf } from './transitions.js';
|
|
14
|
+
import { atomicWrite } from './safe-path.js';
|
|
15
|
+
import { allowedFrom, decisionFits, FEATURE_ONLY, fromOf } from './transitions.js';
|
|
17
16
|
import { ITEM_TYPES, DEFAULT_GATE, GATES, SEVERITIES, UNIT_ITEM_TYPES } from './validate-item.js';
|
|
18
17
|
import { isValidId } from './id.js';
|
|
19
18
|
import { commitMove, addConstraints } from './unit-ops.js';
|
|
@@ -25,6 +24,8 @@ import {
|
|
|
25
24
|
systemFiles,
|
|
26
25
|
buryLine,
|
|
27
26
|
recordLine,
|
|
27
|
+
unplayedCloseCommit,
|
|
28
|
+
fileAt,
|
|
28
29
|
} from './verdict-fs.js';
|
|
29
30
|
import {
|
|
30
31
|
readLiveStrict,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
} from './live.js';
|
|
37
38
|
import { dropInbox } from './inbox.js';
|
|
38
39
|
import { refuseByPhase } from './phase.js';
|
|
40
|
+
import { runHolder } from './main-lock.js';
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -108,15 +110,44 @@ export const frozenHash = (item) =>
|
|
|
108
110
|
.update(`\nlive: ${JSON.stringify(item.data.live ?? null)}`)
|
|
109
111
|
.digest('hex');
|
|
110
112
|
|
|
113
|
+
/** The 0.4.0 `frozen:`: the sha1 of the body alone, without the `live:` delta. */
|
|
114
|
+
const legacyFrozen = (item) =>
|
|
115
|
+
createHash('sha1').update(String(item.body).replace(/\r\n/g, '\n').trim()).digest('hex');
|
|
116
|
+
|
|
111
117
|
/**
|
|
112
|
-
* Whether an unplayed item still matches its `frozen:`. An item closed by 0.4.0 holds the
|
|
113
|
-
*
|
|
114
|
-
* until it is closed again; drop the legacy form once no 0.4.0 unplayed item is left.
|
|
118
|
+
* Whether an unplayed item still matches its `frozen:`. An item closed by 0.4.0 holds the
|
|
119
|
+
* legacy form, which guards the body only, until `gcf init` restamps it (restampFrozen).
|
|
115
120
|
*/
|
|
116
121
|
export const matchesFrozen = (item) =>
|
|
117
|
-
item.data.frozen === frozenHash(item) ||
|
|
118
|
-
|
|
119
|
-
|
|
122
|
+
item.data.frozen === frozenHash(item) || item.data.frozen === legacyFrozen(item);
|
|
123
|
+
|
|
124
|
+
/** Whether the item's `live:` is the one its unplayed close committed. */
|
|
125
|
+
function liveAsClosed(projectDir, item) {
|
|
126
|
+
const close = unplayedCloseCommit(projectDir, item.data.id);
|
|
127
|
+
const text = close && fileAt(projectDir, close, item.file);
|
|
128
|
+
if (!text) return false;
|
|
129
|
+
const live = (data) => JSON.stringify(data.live ?? null);
|
|
130
|
+
|
|
131
|
+
return live(parse(text).data) === live(item.data);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Give every unplayed item still frozen in the 0.4.0 form the full hash, so its `live:` is
|
|
136
|
+
* guarded too — only while that `live:` is still the one its close committed: a later edit is
|
|
137
|
+
* the drift the guard exists for, and must not be stamped in. Returns the ids.
|
|
138
|
+
*/
|
|
139
|
+
export async function restampFrozen(projectDir) {
|
|
140
|
+
const ledger = await buildLedger(projectDir);
|
|
141
|
+
const legacy = ledger.items.filter(
|
|
142
|
+
(i) =>
|
|
143
|
+
i.data.status === 'unplayed' &&
|
|
144
|
+
i.data.frozen === legacyFrozen(i) &&
|
|
145
|
+
liveAsClosed(projectDir, i),
|
|
146
|
+
);
|
|
147
|
+
for (const i of legacy) await writeItem(i, { ...i.data, frozen: frozenHash(i) });
|
|
148
|
+
|
|
149
|
+
return legacy.map((i) => String(i.data.id));
|
|
150
|
+
}
|
|
120
151
|
|
|
121
152
|
/** Children of a feature that are not finished: live in any status but unplayed and parked. */
|
|
122
153
|
function unfinishedChildren(ledger, feature) {
|
|
@@ -136,6 +167,12 @@ async function begin(projectDir, id, move) {
|
|
|
136
167
|
const ledger = await buildLedger(projectDir);
|
|
137
168
|
const item = itemOf(ledger, id);
|
|
138
169
|
need(move, item);
|
|
170
|
+
const run = runHolder(projectDir, id);
|
|
171
|
+
if (run)
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${id} is driven by a harness run (pid ${run}): wait until that run ends; if no harness ` +
|
|
174
|
+
`run is going, delete .gcf-session/runs/${id}.lock`,
|
|
175
|
+
);
|
|
139
176
|
const files = [item.file];
|
|
140
177
|
const steps = [];
|
|
141
178
|
const liveOps = [];
|
|
@@ -608,10 +645,9 @@ const DECIDE = {
|
|
|
608
645
|
},
|
|
609
646
|
kill: killFeature,
|
|
610
647
|
};
|
|
611
|
-
const FEATURE_ONLY = ['close', 'continue', 'kill'];
|
|
612
|
-
|
|
613
648
|
/** Refuse a decision that does not fit the item's type. */
|
|
614
649
|
function fitDecision(choice, item) {
|
|
650
|
+
if (decisionFits(choice, item.data.type)) return;
|
|
615
651
|
const feature = item.data.type === 'feature';
|
|
616
652
|
if (FEATURE_ONLY.includes(choice) !== feature)
|
|
617
653
|
throw new Error(
|
|
@@ -619,8 +655,7 @@ function fitDecision(choice, item) {
|
|
|
619
655
|
? `${item.data.id} is a feature: decide close | continue | kill`
|
|
620
656
|
: `decide ${choice} is for a feature; ${item.data.id} is a ${item.data.type}`,
|
|
621
657
|
);
|
|
622
|
-
|
|
623
|
-
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
658
|
+
throw new Error(`decide ${choice} is for a bug; ${item.data.id} is a ${item.data.type}`);
|
|
624
659
|
}
|
|
625
660
|
|
|
626
661
|
/** The operator's decisions (Methodology §4.1): spikes, decision items, bugs and features.
|
|
@@ -689,6 +724,9 @@ export async function dropItem(projectDir, id, opts = {}) {
|
|
|
689
724
|
return { id, hash: await finish(m, 'drop', id, commit), removal };
|
|
690
725
|
}
|
|
691
726
|
|
|
727
|
+
// A leading hyphen would read as a flag of the agent's command line.
|
|
728
|
+
export const RUN_WORD = /^[A-Za-z0-9][\w.-]*$/;
|
|
729
|
+
|
|
692
730
|
/** The version and channel of a new item: well-formed, and only on a release. */
|
|
693
731
|
function checkRelease(spec) {
|
|
694
732
|
const { type, version, channel } = spec;
|
|
@@ -702,6 +740,20 @@ function checkRelease(spec) {
|
|
|
702
740
|
throw new Error('--release and --channel belong to a release item');
|
|
703
741
|
}
|
|
704
742
|
|
|
743
|
+
const RUN_KEYS = ['model', 'effort'];
|
|
744
|
+
|
|
745
|
+
/** The model and effort a new item gives: the run passes them to the agent's command line. */
|
|
746
|
+
function runFields(spec) {
|
|
747
|
+
for (const k of RUN_KEYS)
|
|
748
|
+
if (spec[k] !== undefined && !RUN_WORD.test(spec[k]))
|
|
749
|
+
throw new Error(
|
|
750
|
+
`--${k} is one word that starts with a letter or a digit ` +
|
|
751
|
+
'(then letters, digits, dots, hyphens, underscores), e.g. opus',
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
return Object.fromEntries(RUN_KEYS.filter((k) => spec[k]).map((k) => [k, spec[k]]));
|
|
755
|
+
}
|
|
756
|
+
|
|
705
757
|
/** Refuse a spec `item new` cannot honour; returns the gate the item gets. */
|
|
706
758
|
function checkNew(ledger, spec) {
|
|
707
759
|
const { id, type, gate, severity } = spec;
|
|
@@ -752,6 +804,7 @@ function optionalFields(spec, gate, since) {
|
|
|
752
804
|
...(spec.milestone ? { milestone: spec.milestone } : {}),
|
|
753
805
|
...(spec.version ? { version: spec.version } : {}),
|
|
754
806
|
...(spec.channel ? { channel: spec.channel } : {}),
|
|
807
|
+
...runFields(spec),
|
|
755
808
|
...(live ? { live } : {}),
|
|
756
809
|
};
|
|
757
810
|
}
|
|
@@ -800,14 +853,18 @@ export async function newItem(projectDir, spec) {
|
|
|
800
853
|
writes.push([p.file, serialize({ ...p.data, children }, p.body)]);
|
|
801
854
|
}
|
|
802
855
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
856
|
+
// `commit` is for a caller with no console to commit from, a board: a run starts from HEAD.
|
|
857
|
+
// The inbox is raw input (merge=union): lines added without a commit ride along with it.
|
|
858
|
+
const inbox = spec.from ? [path.join(root, 'inbox.md')] : [];
|
|
859
|
+
const hash = await commitMove(projectDir, `gcf(item): ${spec.id}`, {
|
|
860
|
+
files: [...writes.map((w) => w[0]), ...inbox],
|
|
861
|
+
inputs: inbox,
|
|
862
|
+
mutate: async () => {
|
|
806
863
|
for (const [f, text] of writes) await atomicWrite(f, text);
|
|
807
864
|
if (spec.from) await dropInbox(root, spec.from);
|
|
808
|
-
await writeIndex(root);
|
|
809
865
|
},
|
|
810
|
-
|
|
866
|
+
commit: spec.commit ?? false,
|
|
867
|
+
});
|
|
811
868
|
|
|
812
|
-
return { item: `gcf/work/${spec.id}.md
|
|
869
|
+
return { item: `gcf/work/${spec.id}.md`, hash };
|
|
813
870
|
}
|
package/package.json
CHANGED
|
@@ -19,10 +19,11 @@ workflow:
|
|
|
19
19
|
1. run `ai-factory gcf audit --json` and read the report
|
|
20
20
|
2. state the first line verbatim: items waiting too long block dependent work, so they lead
|
|
21
21
|
3. for each stale until record: name the file, the trigger that fired and the line to re-check; for each entry of `stale_questions`: name the question id and ask the human for the answer
|
|
22
|
-
4.
|
|
23
|
-
5.
|
|
24
|
-
6.
|
|
25
|
-
7.
|
|
22
|
+
4. for a stale until record of `gcf/open-questions.md`: the D2 assumption its `id` names expired - ask the human to confirm it, then cite `answer: <id>` and remove the record, or to replace the value
|
|
23
|
+
5. read the `design` summary: blocking findings first (run `ai-factory gcf design check` for the list), then the count of ready units
|
|
24
|
+
6. semantic pass, only when the mechanical report is clean or the human asks: read the systems docs of ONE subsystem the human names and report pairs of lines that contradict each other as D1 questions
|
|
25
|
+
7. list the pragmas: each `gcf:allow` is a standing exception, and a growing list is the linter rotting
|
|
26
|
+
8. propose the next actions, ranked, proposed actions <= 3: playtest, answer a D1, take a ready unit, or collapse a stale record
|
|
26
27
|
|
|
27
28
|
invocation:
|
|
28
29
|
- run at session start, after a long break, or on demand
|
package/skills/gcf-init/SKILL.md
CHANGED
|
@@ -15,7 +15,7 @@ preconditions:
|
|
|
15
15
|
- the project is a git repository with AI Factory initialized
|
|
16
16
|
|
|
17
17
|
forbidden_behaviors:
|
|
18
|
-
- do not overwrite an existing gcf/ tree: re-running repairs missing files
|
|
18
|
+
- do not overwrite an existing gcf/ tree: re-running repairs missing files, refreshes the AGENTS.md block and restamps a 0.4.0 `frozen:`
|
|
19
19
|
- do not write pillars the human did not confirm: pillar authorship is human
|
|
20
20
|
- pillar count <= 7, and do not pad the list to reach 7
|
|
21
21
|
|
|
@@ -6,7 +6,7 @@ gcf_context:
|
|
|
6
6
|
|
|
7
7
|
gcf_work:
|
|
8
8
|
- every piece of work is one work item `gcf/work/<id>.md`: find the item before writing code for a feature, a bug or a tech task, or create it with the gcf-card skill
|
|
9
|
-
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later
|
|
9
|
+
- a raw idea, a playtest note or a CI finding that is not work yet goes to `ai-factory gcf inbox add "<text>"`, and the gcf-triage skill sorts it later; a text that starts with a hyphen goes as `inbox add --text="<text>"`
|
|
10
10
|
- before planning an item, run `ai-factory gcf context <id>` and read only the files it lists under `gcf/`
|
|
11
11
|
- drive a ready item with the gcf-run skill when the project has gcf-harness-aif-ext (a batch at once: `ai-factory gcf-harness run <ids...>`), otherwise with aif-plan and aif-implement
|
|
12
12
|
- after the code, write the item's `live:` delta and run `ai-factory gcf verify <id>`: fix what it reports and run it again until every check is green
|
|
@@ -18,6 +18,7 @@ gcf_work:
|
|
|
18
18
|
- the human plays from `ai-factory gcf playtest session`; each free note from that play goes to `ai-factory gcf inbox add --source playtest "<note>"`
|
|
19
19
|
- a release item carries `version:` and `channel:` (`item new <id> --type release --release <v> --channel <c>`): its end writes the `release:` block of `gcf/LIVE.md`
|
|
20
20
|
- `item new` and `take` refuse a type the current phase does not take: run `ai-factory gcf phase` and bring the refusal to the human
|
|
21
|
+
- an item the human wants run on a given model or effort gets `item new <id> --model <m> --effort <e>`: the harness run passes both to the agent
|
|
21
22
|
- a pillar changes only on the human's word: `ai-factory gcf pillar rename <from> <to>`, or `ai-factory gcf pillar retire <id> --reason "<why>"`
|
|
22
23
|
|
|
23
24
|
gcf_forbidden:
|