gcf-aif-ext 0.4.0 → 0.5.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 +26 -1
- package/commands/gcf.js +206 -5
- package/extension.json +1 -1
- package/hooks/pre-commit.js +5 -61
- package/lib/agents-block.js +66 -0
- package/lib/board.js +113 -0
- package/lib/context.js +11 -4
- package/lib/design-check.js +74 -16
- package/lib/gate.js +106 -0
- package/lib/integrate.js +322 -0
- package/lib/live.js +98 -27
- package/lib/main-lock.js +112 -0
- package/lib/phase.js +228 -0
- package/lib/pillars.js +113 -0
- package/lib/playtest-list.js +60 -1
- package/lib/unit-ops.js +28 -2
- package/lib/validate-item.js +10 -0
- package/lib/verdict-fs.js +10 -3
- package/lib/verify.js +72 -0
- package/lib/work-ops.js +98 -12
- package/package.json +4 -3
- package/schemas/item.schema.json +4 -0
- package/skills/gcf-card/SKILL.md +10 -8
- package/skills/gcf-init/SKILL.md +6 -3
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/agents-block.md +36 -0
- package/templates/config.yml +2 -0
package/lib/design-check.js
CHANGED
|
@@ -496,6 +496,10 @@ function ledgerIssues(ledger) {
|
|
|
496
496
|
out.push({ check: 'M12', severity: 'block', file, line: 1, id, message });
|
|
497
497
|
if (live.length > 1)
|
|
498
498
|
add(`${id} lives in ${live.length} places: ${live.map((p) => p.file).join(', ')}`);
|
|
499
|
+
// An id ends once; two ends (a merge of branches that each ended it) leave its dependents
|
|
500
|
+
// counting it as played or dead depending on which line wins.
|
|
501
|
+
if (terminal.length > 1)
|
|
502
|
+
add(`${id} ended ${terminal.join(' and ')}: keep one line in history.md or graveyard.md`);
|
|
499
503
|
if (live.length && terminal.length) {
|
|
500
504
|
add(
|
|
501
505
|
`${id} is ${terminal.join(' and ')} already: ids are never reused — give the new one ` +
|
|
@@ -508,6 +512,49 @@ function ledgerIssues(ledger) {
|
|
|
508
512
|
return out;
|
|
509
513
|
}
|
|
510
514
|
|
|
515
|
+
/**
|
|
516
|
+
* M2 over `depends:` of units and items together: a cycle never gets played — every id in it
|
|
517
|
+
* waits for the next one. Each cycle is reported once, from the id DFS meets it at.
|
|
518
|
+
*/
|
|
519
|
+
export function dependsCycles(ledger) {
|
|
520
|
+
/** @type {Map<string, string[]>} */
|
|
521
|
+
const graph = new Map();
|
|
522
|
+
for (const u of ledger.design.flatMap((d) => d.units)) {
|
|
523
|
+
const list = listOf(u.header.depends ?? '');
|
|
524
|
+
graph.set(u.id, Array.isArray(list) ? list : []);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (const c of ledger.items) graph.set(String(c.data.id), c.data.depends ?? []);
|
|
528
|
+
/** @type {Issue[]} */
|
|
529
|
+
const out = [];
|
|
530
|
+
const state = new Map(); // id → 'open' while on the DFS path, 'done' after
|
|
531
|
+
const visit = (id, trail) => {
|
|
532
|
+
if (state.get(id) === 'done') return;
|
|
533
|
+
if (state.get(id) === 'open') {
|
|
534
|
+
const cycle = [...trail.slice(trail.indexOf(id)), id];
|
|
535
|
+
const file = ledger.where(id).live[0]?.file ?? 'design';
|
|
536
|
+
out.push({
|
|
537
|
+
check: 'M2',
|
|
538
|
+
severity: 'block',
|
|
539
|
+
file,
|
|
540
|
+
line: 1,
|
|
541
|
+
id,
|
|
542
|
+
message: `depends cycle ${cycle.join(' -> ')}: none of them can be played first`,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
state.set(id, 'open');
|
|
549
|
+
for (const d of graph.get(id) ?? []) visit(d, [...trail, id]);
|
|
550
|
+
state.set(id, 'done');
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
for (const id of graph.keys()) visit(id, []);
|
|
554
|
+
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
511
558
|
/** M14: backlog.md is the order of the units in design/, each exactly once. */
|
|
512
559
|
function backlogIssues(text, unitIds) {
|
|
513
560
|
/** @type {Issue[]} */
|
|
@@ -577,6 +624,31 @@ function pillarReport(units, ctx) {
|
|
|
577
624
|
};
|
|
578
625
|
}
|
|
579
626
|
|
|
627
|
+
/** A unit's line in the report: its TBD count and whether it is ready to take. */
|
|
628
|
+
function unitSummary(u, own, values, ctx) {
|
|
629
|
+
const pillars = listOf(u.header.pillars ?? '');
|
|
630
|
+
// Once PILLARS.md exists, a mechanic or resource says which pillar it serves before work:
|
|
631
|
+
// `take` would turn the TBD into an empty list and the unit would serve nothing silently.
|
|
632
|
+
const pillarsTbd =
|
|
633
|
+
pillars === 'TBD' && ctx.pillarIds.length && CARD_TYPES.includes(u.header.type) ? 1 : 0;
|
|
634
|
+
const tbd = values.filter((v) => isTbd(v.value)).length + pillarsTbd;
|
|
635
|
+
const blockedBy = ctx.questions.d1.get(u.id) ?? [];
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
id: u.id,
|
|
639
|
+
file: u.file,
|
|
640
|
+
system: u.system,
|
|
641
|
+
type: u.header.type ?? null,
|
|
642
|
+
milestone: u.header.milestone ?? 'TBD',
|
|
643
|
+
pillars,
|
|
644
|
+
pillars_owed: Boolean(pillarsTbd),
|
|
645
|
+
values: values.length,
|
|
646
|
+
tbd,
|
|
647
|
+
blocked_by: blockedBy,
|
|
648
|
+
ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
580
652
|
/**
|
|
581
653
|
* Run every design check over a project. `blocking` counts the issues that refuse a commit;
|
|
582
654
|
* the rest of the report (completeness, readiness, pillars) is a reading, not a gate.
|
|
@@ -591,28 +663,14 @@ export async function designCheck(projectDir) {
|
|
|
591
663
|
for (const u of ledger.design.flatMap((d) => d.units)) {
|
|
592
664
|
const { issues: own, values } = await checkUnit(u, ctx, ledger);
|
|
593
665
|
issues.push(...own);
|
|
594
|
-
|
|
595
|
-
const blockedBy = ctx.questions.d1.get(u.id) ?? [];
|
|
596
|
-
const pillars = listOf(u.header.pillars ?? '');
|
|
597
|
-
units.push({
|
|
598
|
-
id: u.id,
|
|
599
|
-
file: u.file,
|
|
600
|
-
system: u.system,
|
|
601
|
-
type: u.header.type ?? null,
|
|
602
|
-
milestone: u.header.milestone ?? 'TBD',
|
|
603
|
-
pillars,
|
|
604
|
-
values: values.length,
|
|
605
|
-
tbd,
|
|
606
|
-
blocked_by: blockedBy,
|
|
607
|
-
ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
|
|
608
|
-
});
|
|
666
|
+
units.push(unitSummary(u, own, values, ctx));
|
|
609
667
|
}
|
|
610
668
|
|
|
611
669
|
for (const c of ledger.items) issues.push(...(await checkItem(c, ctx, ledger)));
|
|
612
670
|
issues.push(...(await unplayedIssues(root, ledger)));
|
|
613
671
|
for (const doc of ['game', 'glossary']) issues.push(...(await checkDoc(root, doc, ctx)));
|
|
614
672
|
issues.push(...preambleIssues(ledger.design));
|
|
615
|
-
issues.push(...ledgerIssues(ledger), ...questionIssues(ctx, ledger));
|
|
673
|
+
issues.push(...ledgerIssues(ledger), ...dependsCycles(ledger), ...questionIssues(ctx, ledger));
|
|
616
674
|
if (ledger.design.length) {
|
|
617
675
|
const backlog = await readIf(path.join(root, 'backlog.md'));
|
|
618
676
|
issues.push(...backlogIssues(backlog, new Set(units.map((u) => u.id))));
|
package/lib/gate.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// The GCF checks of the pre-commit gate (GCF-PRD §13.1) over the working tree, cheap ones first.
|
|
2
|
+
// The gate refuses on the first problem; `gcf verify` reports them all as the GCF part of the DoD.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { listSystems, legacyCards, readConfig } from './gcf-paths.js';
|
|
5
|
+
import { phasesOf } from './phase.js';
|
|
6
|
+
import { validateItem, validateUntil } from './validate-item.js';
|
|
7
|
+
import { stemMatches } from './id.js';
|
|
8
|
+
import { lint } from './linter.js';
|
|
9
|
+
import { buildLedger } from './ledger.js';
|
|
10
|
+
import { designCheck } from './design-check.js';
|
|
11
|
+
import { matchesFrozen } from './work-ops.js';
|
|
12
|
+
import { checkLive } from './live.js';
|
|
13
|
+
|
|
14
|
+
/** Problems of form: legacy cards, item frontmatter and filenames, `until` grammar. */
|
|
15
|
+
function formProblems(root, ledger, systems) {
|
|
16
|
+
const problems = [];
|
|
17
|
+
// Schema 2 cards are not read by 0.4: left in place they would silently stop being checked.
|
|
18
|
+
const legacy = legacyCards(root);
|
|
19
|
+
if (legacy.length)
|
|
20
|
+
problems.push(
|
|
21
|
+
`gcf/cards/ holds schema 2 card(s) (${legacy.join(', ')}): GCF 0.4 keeps work in ` +
|
|
22
|
+
'gcf/work/<id>.md with schema 3 and does not migrate — put each card back into ' +
|
|
23
|
+
'gcf/design/ as a unit and run ai-factory gcf take, then delete gcf/cards/',
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
|
|
27
|
+
for (const c of ledger.items) {
|
|
28
|
+
const errors = [
|
|
29
|
+
...validateItem(c.data),
|
|
30
|
+
...validateUntil(c.body),
|
|
31
|
+
...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
|
|
32
|
+
];
|
|
33
|
+
if (errors.length) problems.push(`work/${c.name}: ${errors[0]}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const s of systems) {
|
|
37
|
+
const errors = validateUntil(s.body);
|
|
38
|
+
if (errors.length) problems.push(`systems/${s.name}: ${errors[0]}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return problems;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The `phases:` of config.yml, and the LIVE.md phase among them (Methodology §10). */
|
|
45
|
+
async function phaseProblems(root, phase) {
|
|
46
|
+
let names;
|
|
47
|
+
try {
|
|
48
|
+
names = phasesOf(await readConfig(root)).map((p) => p.name);
|
|
49
|
+
} catch {
|
|
50
|
+
// A broken config is for the command that reads it to refuse: a kill must still commit.
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return phase && !names.includes(phase)
|
|
55
|
+
? [`LIVE.md: phase ${phase} is not a phase of the project (${names.join(', ')})`]
|
|
56
|
+
: [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every problem the gate refuses a commit for, one line each, and the ledger it read. */
|
|
60
|
+
export async function gateProblems(projectDir) {
|
|
61
|
+
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
62
|
+
const ledger = await buildLedger(projectDir);
|
|
63
|
+
const systems = await listSystems(root);
|
|
64
|
+
const problems = formProblems(root, ledger, systems);
|
|
65
|
+
// The checks below read these files and trust their form.
|
|
66
|
+
if (problems.length) return { problems, ledger };
|
|
67
|
+
|
|
68
|
+
// An unplayed item's body and live: are the source of its systems/ lines and its capability
|
|
69
|
+
// (Methodology §5.4): edited after its close, a tune or a kill would take the wrong ones out.
|
|
70
|
+
for (const c of ledger.items.filter((x) => x.data.status === 'unplayed')) {
|
|
71
|
+
if (!matchesFrozen(c))
|
|
72
|
+
problems.push(
|
|
73
|
+
`work/${c.name}: an unplayed item's body and live: are frozen — restore them, tune it, ` +
|
|
74
|
+
'then edit',
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Design model and item bodies: every finding of severity `block` refuses the commit.
|
|
79
|
+
for (const i of (await designCheck(projectDir)).issues.filter((x) => x.severity === 'block'))
|
|
80
|
+
problems.push(`${i.file}:${i.line}: ${i.check}: ${i.message}`);
|
|
81
|
+
|
|
82
|
+
// Dependencies are held by the moves, not here (Methodology §4.1): `submit` refuses an unplayed
|
|
83
|
+
// one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
|
|
84
|
+
// a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
|
|
85
|
+
|
|
86
|
+
// LIVE.md form: a malformed record would hide from every reader of the snapshot. A broken ref
|
|
87
|
+
// into gcf/ is this commit's own doing (a move deleted the file); one into the code is not
|
|
88
|
+
// refused — a code commit outside gcf/ may have moved it and the gate never saw that.
|
|
89
|
+
const live = await checkLive(projectDir);
|
|
90
|
+
const form = [
|
|
91
|
+
...live.problems,
|
|
92
|
+
...live.issues.filter((i) => !live.problems.includes(i) && /summary|twice|refs gcf\//.test(i)),
|
|
93
|
+
];
|
|
94
|
+
problems.push(...form.map((f) => `LIVE.md: ${f}`));
|
|
95
|
+
// A phase the project does not name would refuse every later item new and take.
|
|
96
|
+
problems.push(...(await phaseProblems(root, live.phase)));
|
|
97
|
+
|
|
98
|
+
// 5: П2 linter over systems docs.
|
|
99
|
+
for (const s of systems) {
|
|
100
|
+
const { issues } = lint(s.body);
|
|
101
|
+
if (issues.length)
|
|
102
|
+
problems.push(`systems/${s.name}:${issues[0].line}: ${issues[0].rule}: ${issues[0].text}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { problems, ledger };
|
|
106
|
+
}
|
package/lib/integrate.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// The integrator (Methodology §7): an item runs in its worktree on branch `work/<id>`, and the
|
|
2
|
+
// branch is ready once its tip carries the trailer `GCF-Verified: <id>` — readiness lives in git,
|
|
3
|
+
// not in memory. `integrate` merges such a branch into main and records the item's move there,
|
|
4
|
+
// under the main lock; without an id it picks up every verified branch a dead run left behind.
|
|
5
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { buildLedger } from './ledger.js';
|
|
10
|
+
import { parse } from './frontmatter.js';
|
|
11
|
+
import { withMainLock } from './main-lock.js';
|
|
12
|
+
import { verifyItem, deltaProblems } from './verify.js';
|
|
13
|
+
import { submitItem, closeItem, blockItem } from './work-ops.js';
|
|
14
|
+
import { isValidId } from './id.js';
|
|
15
|
+
|
|
16
|
+
const TRAILER = 'GCF-Verified';
|
|
17
|
+
export const branchOf = (id) => `work/${id}`;
|
|
18
|
+
export const worktreeOf = (projectDir, id) =>
|
|
19
|
+
path.join(path.resolve(projectDir), '.gcf-session', 'worktrees', id);
|
|
20
|
+
|
|
21
|
+
function git(cwd, args, { check = true } = {}) {
|
|
22
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
23
|
+
const err = (res.stderr || res.stdout || '').trim();
|
|
24
|
+
if (check && res.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${err}`);
|
|
25
|
+
|
|
26
|
+
return { ok: res.status === 0, out: (res.stdout ?? '').trim(), err };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `.gcf-session/` stays out of `git status` without a tracked .gitignore change. */
|
|
30
|
+
async function excludeSession(projectDir) {
|
|
31
|
+
const common = path.resolve(projectDir, git(projectDir, ['rev-parse', '--git-common-dir']).out);
|
|
32
|
+
const file = path.join(common, 'info', 'exclude');
|
|
33
|
+
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
34
|
+
if (text.split(/\r?\n/).includes('/.gcf-session/')) return;
|
|
35
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
36
|
+
await appendFile(file, `${text && !text.endsWith('\n') ? '\n' : ''}/.gcf-session/\n`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const METRICS = ':(exclude)gcf/.metrics';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Bring the main checkout's branch into a resumed worktree: the operator's answer from
|
|
43
|
+
* `unblock`, a newer LIVE.md and the other items' states live on main only.
|
|
44
|
+
*/
|
|
45
|
+
function syncWithMain(projectDir, dir) {
|
|
46
|
+
const main = git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
|
|
47
|
+
if (main === 'HEAD' || main.startsWith('work/')) return {};
|
|
48
|
+
const res = git(dir, ['merge', '--no-edit', '-m', `gcf(sync): ${main}`, main], { check: false });
|
|
49
|
+
if (res.ok) return {};
|
|
50
|
+
if (git(dir, ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { check: false }).ok)
|
|
51
|
+
git(dir, ['merge', '--abort'], { check: false });
|
|
52
|
+
|
|
53
|
+
return { sync: `${main} does not merge into the worktree: ${res.err}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A worktree for a ready item: `.gcf-session/worktrees/<id>` on branch `work/<id>` from HEAD.
|
|
58
|
+
* An existing one is resumed with main merged in; `sync` names a merge that did not go.
|
|
59
|
+
*/
|
|
60
|
+
export async function addWorktree(projectDir, id) {
|
|
61
|
+
if (!isValidId(id)) throw new Error(`${id} is not an item id`);
|
|
62
|
+
const item = (await buildLedger(projectDir)).item(id);
|
|
63
|
+
if (!item) throw new Error(`${id} is not a work item of gcf/work/`);
|
|
64
|
+
const dir = worktreeOf(projectDir, id);
|
|
65
|
+
if (existsSync(dir))
|
|
66
|
+
return {
|
|
67
|
+
id,
|
|
68
|
+
path: dir,
|
|
69
|
+
branch: branchOf(id),
|
|
70
|
+
created: false,
|
|
71
|
+
...syncWithMain(projectDir, dir),
|
|
72
|
+
};
|
|
73
|
+
if (item.data.status !== 'ready')
|
|
74
|
+
throw new Error(`${id} is ${item.data.status}: a run starts on a ready item`);
|
|
75
|
+
await excludeSession(projectDir);
|
|
76
|
+
// A worktree folder deleted by hand leaves its registration behind: add would refuse.
|
|
77
|
+
git(projectDir, ['worktree', 'prune']);
|
|
78
|
+
const ref = `refs/heads/${branchOf(id)}`;
|
|
79
|
+
const exists = git(projectDir, ['rev-parse', '--verify', '--quiet', ref], { check: false }).ok;
|
|
80
|
+
const from = exists ? [dir, branchOf(id)] : ['-b', branchOf(id), dir, 'HEAD'];
|
|
81
|
+
git(projectDir, ['worktree', 'add', ...from]);
|
|
82
|
+
|
|
83
|
+
return { id, path: dir, branch: branchOf(id), created: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* `verify --mark`: the DoD is green on branch `work/<id>` with everything committed — an empty
|
|
88
|
+
* commit carrying the trailer says so in git. Refuses on red checks or uncommitted work.
|
|
89
|
+
*/
|
|
90
|
+
export async function markVerified(worktreeDir, id, opts = {}) {
|
|
91
|
+
const branch = git(worktreeDir, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
|
|
92
|
+
if (branch !== branchOf(id))
|
|
93
|
+
throw new Error(`${id} is verified on its branch ${branchOf(id)}, this is ${branch}`);
|
|
94
|
+
if (git(worktreeDir, ['status', '--porcelain', '--', '.', METRICS]).out)
|
|
95
|
+
throw new Error('commit the work first: the trailer marks a commit, not a working tree');
|
|
96
|
+
const r = await verifyItem(worktreeDir, id, opts);
|
|
97
|
+
if (!r.ok) return { ...r, marked: false };
|
|
98
|
+
const msg = ['-m', `gcf(verified): ${id}`, '-m', `${TRAILER}: ${id}`];
|
|
99
|
+
git(worktreeDir, ['commit', '--allow-empty', ...msg]);
|
|
100
|
+
|
|
101
|
+
return { ...r, marked: true };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isVerified(projectDir, id) {
|
|
105
|
+
const format = `--format=%(trailers:key=${TRAILER},valueonly)`;
|
|
106
|
+
const trailers = git(projectDir, ['log', '-1', format, branchOf(id)], { check: false }).out;
|
|
107
|
+
|
|
108
|
+
return trailers.split('\n').some((v) => v.trim() === id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The ids of branches `work/<id>` whose tip carries `GCF-Verified: <id>`. */
|
|
112
|
+
export function verifiedBranches(projectDir) {
|
|
113
|
+
const refs = git(projectDir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/work/']);
|
|
114
|
+
|
|
115
|
+
return refs.out
|
|
116
|
+
.split('\n')
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.map((b) => b.slice('work/'.length))
|
|
119
|
+
.filter((id) => isVerified(projectDir, id));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Take the worktree and the branch away once the branch is in main (or tagged, for a spike).
|
|
124
|
+
* Without --force: work left uncommitted in the worktree stops the removal and is named.
|
|
125
|
+
*/
|
|
126
|
+
function cleanUp(projectDir, id, { force = false } = {}) {
|
|
127
|
+
const notes = [];
|
|
128
|
+
const dir = worktreeOf(projectDir, id);
|
|
129
|
+
if (existsSync(dir)) {
|
|
130
|
+
// The run's own audit and context lines are no work to keep.
|
|
131
|
+
git(dir, ['checkout', '--', 'gcf/.metrics'], { check: false });
|
|
132
|
+
git(dir, ['clean', '-fdq', '--', 'gcf/.metrics'], { check: false });
|
|
133
|
+
const res = git(projectDir, ['worktree', 'remove', dir], { check: false });
|
|
134
|
+
if (!res.ok) notes.push(`worktree kept: ${res.err}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
git(projectDir, ['worktree', 'prune'], { check: false });
|
|
138
|
+
const res = git(projectDir, ['branch', force ? '-D' : '-d', branchOf(id)], { check: false });
|
|
139
|
+
if (!res.ok) notes.push(`branch kept: ${res.err}`);
|
|
140
|
+
|
|
141
|
+
return notes.length ? { cleanup: notes } : {};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The item file at `rev` without its `live:`: what a branch must leave as main has it. */
|
|
145
|
+
function itemBeyondLive(projectDir, rev, id) {
|
|
146
|
+
const res = git(projectDir, ['show', `${rev}:./gcf/work/${id}.md`], { check: false });
|
|
147
|
+
if (!res.ok) return null;
|
|
148
|
+
const { data, body } = parse(res.out);
|
|
149
|
+
const { live, ...rest } = data;
|
|
150
|
+
|
|
151
|
+
return JSON.stringify([rest, String(body).trim()]);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The branch may change in gcf/ only its own item's `live:` (Methodology §5.2): status, other
|
|
156
|
+
* items and the config move on main through their commands.
|
|
157
|
+
*/
|
|
158
|
+
function foreignGcfEdits(projectDir, id) {
|
|
159
|
+
const own = `gcf/work/${id}.md`;
|
|
160
|
+
const range = `HEAD...${branchOf(id)}`;
|
|
161
|
+
const changed = git(projectDir, ['diff', '--name-only', range, '--', 'gcf/'])
|
|
162
|
+
.out.split('\n')
|
|
163
|
+
.filter((f) => f && f !== own && f !== 'gcf/INDEX.md' && !f.startsWith('gcf/.metrics/'));
|
|
164
|
+
const base = git(projectDir, ['merge-base', 'HEAD', branchOf(id)]).out;
|
|
165
|
+
if (itemBeyondLive(projectDir, base, id) !== itemBeyondLive(projectDir, branchOf(id), id))
|
|
166
|
+
changed.push(`${own} beyond its live:`);
|
|
167
|
+
|
|
168
|
+
return changed;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether the item's move was recorded after its branch merged: a crash may fall in between. */
|
|
172
|
+
function movedSinceMerge(projectDir, id) {
|
|
173
|
+
const grep = ['--fixed-strings', `--grep=gcf(integrate): ${id}`];
|
|
174
|
+
const merge = git(projectDir, ['log', '-1', '--format=%H', ...grep]).out;
|
|
175
|
+
if (!merge) return false;
|
|
176
|
+
const after = git(projectDir, ['log', '--format=%s', `${merge}..HEAD`]).out.split('\n');
|
|
177
|
+
const move = new RegExp(`^gcf\\((submit|close|block)\\): ${id}( |$)`);
|
|
178
|
+
|
|
179
|
+
return after.some((s) => move.test(s));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The move a merged item ends its run with. Its delta is checked against main first — the
|
|
184
|
+
* worktree's LIVE.md may be behind. A move the item refuses blocks it with the reason; when even
|
|
185
|
+
* the block fails, the error goes up and the next integrate records the move again.
|
|
186
|
+
*/
|
|
187
|
+
async function recordMove(projectDir, item) {
|
|
188
|
+
const { id, gate } = item.data;
|
|
189
|
+
const stale = await deltaProblems(projectDir, item);
|
|
190
|
+
try {
|
|
191
|
+
if (stale.length) throw new Error(stale.join('; '));
|
|
192
|
+
if (gate === 'auto') return { move: 'close', ...(await closeItem(projectDir, id)) };
|
|
193
|
+
|
|
194
|
+
return { move: 'submit', ...(await submitItem(projectDir, id)) };
|
|
195
|
+
} catch (e) {
|
|
196
|
+
const reason = `integrate: ${e.message}`;
|
|
197
|
+
|
|
198
|
+
return { move: 'block', reason, ...(await blockItem(projectDir, id, { reason })) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** A spike's code never reaches main (§5.5): only its plan does, and a tag keeps the branch. */
|
|
203
|
+
async function integrateSpike(projectDir, item) {
|
|
204
|
+
const id = String(item.data.id);
|
|
205
|
+
const plan = item.data.plan ?? `.ai-factory/plans/${id}.md`;
|
|
206
|
+
if (git(projectDir, ['cat-file', '-e', `${branchOf(id)}:./${plan}`], { check: false }).ok) {
|
|
207
|
+
git(projectDir, ['checkout', branchOf(id), '--', plan]);
|
|
208
|
+
// A plan main already holds as it is (a rerun after a crash) has nothing to commit.
|
|
209
|
+
if (!git(projectDir, ['diff', '--cached', '--quiet', '--', plan], { check: false }).ok)
|
|
210
|
+
git(projectDir, ['commit', '-m', `gcf(integrate): ${id} plan`, '--only', '--', plan]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
git(projectDir, ['tag', '-f', `spike/${id}`, branchOf(id)]);
|
|
214
|
+
const moved = await recordMove(projectDir, item);
|
|
215
|
+
|
|
216
|
+
return { ...moved, ...cleanUp(projectDir, id, { force: true }) };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Merge the branch; null when it went in, else the conflict or the refusal. */
|
|
220
|
+
function mergeBranch(projectDir, id) {
|
|
221
|
+
const args = ['merge', '--no-ff', '--no-edit', '-m', `gcf(integrate): ${id}`, branchOf(id)];
|
|
222
|
+
const merge = git(projectDir, args, { check: false });
|
|
223
|
+
if (merge.ok) return null;
|
|
224
|
+
const head = ['rev-parse', '-q', '--verify', 'MERGE_HEAD'];
|
|
225
|
+
if (!git(projectDir, head, { check: false }).ok)
|
|
226
|
+
return { error: `merge of ${branchOf(id)} refused: ${merge.err}` };
|
|
227
|
+
git(projectDir, ['merge', '--abort'], { check: false });
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
conflict:
|
|
231
|
+
`${branchOf(id)} conflicts with main: in its worktree merge main, resolve, run ` +
|
|
232
|
+
`gcf verify ${id} --mark again, then gcf integrate ${id}`,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A branch main already holds: its move may still be owed, after a crash in between. */
|
|
237
|
+
async function integrateMerged(projectDir, id, item) {
|
|
238
|
+
if (!item || item.data.status !== 'ready' || movedSinceMerge(projectDir, id))
|
|
239
|
+
return { id, skipped: `${branchOf(id)} is in main already`, ...cleanUp(projectDir, id) };
|
|
240
|
+
|
|
241
|
+
return { id, ...(await recordMove(projectDir, item)), ...cleanUp(projectDir, id) };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function integrateOne(projectDir, id) {
|
|
245
|
+
const item = (await buildLedger(projectDir)).item(id);
|
|
246
|
+
const ancestor = ['merge-base', '--is-ancestor', branchOf(id), 'HEAD'];
|
|
247
|
+
if (git(projectDir, ancestor, { check: false }).ok) return integrateMerged(projectDir, id, item);
|
|
248
|
+
if (!item || item.data.status !== 'ready')
|
|
249
|
+
return { id, skipped: `${id} is ${item ? item.data.status : 'not a work item'} on main` };
|
|
250
|
+
if (item.data.type === 'spike') return { id, ...(await integrateSpike(projectDir, item)) };
|
|
251
|
+
const foreign = foreignGcfEdits(projectDir, id);
|
|
252
|
+
if (foreign.length)
|
|
253
|
+
return {
|
|
254
|
+
id,
|
|
255
|
+
error:
|
|
256
|
+
`${branchOf(id)} changes gcf/ beyond the item's live: (${foreign.join(', ')}) — take ` +
|
|
257
|
+
'those edits out of the branch: they reach main through their commands',
|
|
258
|
+
};
|
|
259
|
+
const failed = mergeBranch(projectDir, id);
|
|
260
|
+
if (failed) return { id, ...failed };
|
|
261
|
+
const moved = await recordMove(projectDir, (await buildLedger(projectDir)).item(id));
|
|
262
|
+
|
|
263
|
+
return { id, ...moved, ...cleanUp(projectDir, id) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Audit and context append to tracked logs in gcf/.metrics on every call: commit them, or a
|
|
268
|
+
* branch bringing its own lines could not merge over them. Union merge joins the lines.
|
|
269
|
+
*/
|
|
270
|
+
function commitMetrics(projectDir) {
|
|
271
|
+
const dirty = git(projectDir, [
|
|
272
|
+
'status',
|
|
273
|
+
'--porcelain',
|
|
274
|
+
'--untracked-files=no',
|
|
275
|
+
'--',
|
|
276
|
+
'gcf/.metrics',
|
|
277
|
+
]);
|
|
278
|
+
if (dirty.out)
|
|
279
|
+
git(projectDir, [
|
|
280
|
+
'commit',
|
|
281
|
+
'-q',
|
|
282
|
+
'--no-verify',
|
|
283
|
+
'-m',
|
|
284
|
+
'gcf(metrics): run logs',
|
|
285
|
+
'--only',
|
|
286
|
+
'--',
|
|
287
|
+
'gcf/.metrics',
|
|
288
|
+
]);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Merge verified branches into main and record each item's move: gate auto closes, the others
|
|
293
|
+
* submit; a move the item refuses blocks it with the reason. `ids` empty: every verified branch.
|
|
294
|
+
* One item's failure is reported and the others still go.
|
|
295
|
+
*/
|
|
296
|
+
export async function integrate(projectDir, ids = []) {
|
|
297
|
+
const branch = git(projectDir, ['rev-parse', '--abbrev-ref', 'HEAD']).out;
|
|
298
|
+
if (branch === 'HEAD' || branch.startsWith('work/'))
|
|
299
|
+
throw new Error(`integrate runs in the main checkout on its branch, not on ${branch}`);
|
|
300
|
+
|
|
301
|
+
return withMainLock(projectDir, async () => {
|
|
302
|
+
if (git(projectDir, ['status', '--porcelain', '--untracked-files=no', '--', '.', METRICS]).out)
|
|
303
|
+
throw new Error('main has uncommitted changes: commit or stash them before integrating');
|
|
304
|
+
commitMetrics(projectDir);
|
|
305
|
+
for (const id of ids)
|
|
306
|
+
if (!isVerified(projectDir, id))
|
|
307
|
+
throw new Error(
|
|
308
|
+
`${branchOf(id)} carries no ${TRAILER}: ${id} trailer — run gcf verify ${id} --mark ` +
|
|
309
|
+
'in its worktree once the DoD is green',
|
|
310
|
+
);
|
|
311
|
+
const results = [];
|
|
312
|
+
for (const id of ids.length ? ids : verifiedBranches(projectDir)) {
|
|
313
|
+
try {
|
|
314
|
+
results.push(await integrateOne(projectDir, id));
|
|
315
|
+
} catch (e) {
|
|
316
|
+
results.push({ id, error: e.message });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return results;
|
|
321
|
+
});
|
|
322
|
+
}
|