gcf-aif-ext 0.7.10 → 0.7.14
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 +230 -216
- package/commands/gcf.js +58 -21
- package/extension.json +1 -1
- package/injections/commit-status.md +11 -9
- package/injections/plan-context.md +2 -1
- package/injections/review-pillars.md +1 -0
- package/lib/anchors.js +58 -1
- package/lib/board.js +48 -3
- package/lib/integrate.js +23 -12
- package/lib/metrics.js +41 -0
- package/lib/verify-stamp.js +102 -0
- package/lib/work-ops.js +34 -2
- package/package.json +1 -1
- package/profiles/pillars.md +1 -1
- package/profiles/system.md +1 -1
- package/skills/gcf-adopt/SKILL.md +1 -1
- package/skills/gcf-card/SKILL.md +4 -3
- package/skills/gcf-ingest/SKILL.md +4 -3
- package/skills/gcf-init/SKILL.md +2 -2
- package/skills/gcf-triage/SKILL.md +1 -1
- package/skills/gcf-verdict/SKILL.md +2 -1
- package/skills/gcf-vision/SKILL.md +1 -1
- package/templates/agents-block.md +42 -40
- package/templates/item.md +2 -2
package/commands/gcf.js
CHANGED
|
@@ -19,6 +19,23 @@ const out = (json, value, human) =>
|
|
|
19
19
|
const HARNESS = 'gcf-harness-aif-ext';
|
|
20
20
|
|
|
21
21
|
const CLAIMED = 'the agent session that claimed the item (gcf claim) records its own move';
|
|
22
|
+
/** close and submit from a command: they need a green verify unless told why not. */
|
|
23
|
+
const verifiedMove = (o) => ({
|
|
24
|
+
commit: o.commit,
|
|
25
|
+
requireVerify: true,
|
|
26
|
+
unverified: o.unverified === undefined ? undefined : reasonOf(o.unverified),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** The reason of `--unverified`, one line in the commit's subject; an empty one is no reason. */
|
|
30
|
+
function reasonOf(text) {
|
|
31
|
+
const why = String(text).replace(/\s+/g, ' ').trim();
|
|
32
|
+
if (!why) throw new Error('--unverified needs the reason the move goes without a green verify');
|
|
33
|
+
|
|
34
|
+
return why;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const UNVERIFIED =
|
|
38
|
+
'move without a green gcf verify of the code as it is now; the reason goes into the commit';
|
|
22
39
|
|
|
23
40
|
/** `--claimed`: this move comes from the session that holds the item's mark. */
|
|
24
41
|
const claimedBy = (o, id) => {
|
|
@@ -442,31 +459,25 @@ function registerAnchor(gcf) {
|
|
|
442
459
|
'Import a GDD into gcf/source/ with stable section anchors; on re-import prints the delta',
|
|
443
460
|
)
|
|
444
461
|
.option('--json', 'machine output')
|
|
462
|
+
.option('--done', 'the import is written: the next anchor counts its delta from this one')
|
|
445
463
|
.action(async (file, opts) => {
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const rewritten = delta ? await anchors.rewriteAnchors(process.cwd(), delta.renamed) : [];
|
|
456
|
-
const rebaselined = Boolean(oldMap?.rebaselined);
|
|
457
|
-
const result = {
|
|
458
|
-
file: `gcf/source/${base}`,
|
|
459
|
-
sections: sections.map((s) => ({ anchor: s.anchor, heading: s.heading, lines: s.lines })),
|
|
460
|
-
delta,
|
|
461
|
-
rebaselined,
|
|
462
|
-
rewritten,
|
|
463
|
-
};
|
|
464
|
-
out(opts.json, result, (r) =>
|
|
464
|
+
const { anchorImport, finishImport } = await lib('anchors');
|
|
465
|
+
if (opts.done) {
|
|
466
|
+
const done = await finishImport(process.cwd(), file);
|
|
467
|
+
console.log(done ? `import of ${path.basename(file)} finished` : 'no import to finish');
|
|
468
|
+
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
out(opts.json, await anchorImport(process.cwd(), file), (r) =>
|
|
465
473
|
[
|
|
466
474
|
...r.sections.map(
|
|
467
475
|
(s) => `${s.anchor} L${s.lines[0]}-${s.lines[1]} ${s.heading ?? '(preamble)'}`,
|
|
468
476
|
),
|
|
469
477
|
anchorDeltaLine(r),
|
|
478
|
+
...(r.resumed
|
|
479
|
+
? ['resumed: an earlier import of this file did not finish, the delta counts from it']
|
|
480
|
+
: []),
|
|
470
481
|
...r.rewritten.map((f) => `renamed anchors rewritten in gcf/${f}`),
|
|
471
482
|
].join('\n'),
|
|
472
483
|
);
|
|
@@ -747,10 +758,11 @@ function registerItemMoves(gcf) {
|
|
|
747
758
|
.command('submit <id>')
|
|
748
759
|
.description('ready → awaiting-verdict / awaiting-decision (commit gcf(submit))')
|
|
749
760
|
.option('--claimed', CLAIMED)
|
|
761
|
+
.option('--unverified <why>', UNVERIFIED)
|
|
750
762
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
751
763
|
.action(async (id, o) => {
|
|
752
764
|
claimedBy(o, id);
|
|
753
|
-
const r = await (await ops()).submitItem(process.cwd(), id,
|
|
765
|
+
const r = await (await ops()).submitItem(process.cwd(), id, verifiedMove(o));
|
|
754
766
|
console.log(`${r.id} → ${r.status}${shortHash(r.hash)}`);
|
|
755
767
|
});
|
|
756
768
|
gcf
|
|
@@ -763,12 +775,13 @@ function registerItemMoves(gcf) {
|
|
|
763
775
|
[],
|
|
764
776
|
)
|
|
765
777
|
.option('--claimed', CLAIMED)
|
|
778
|
+
.option('--unverified <why>', UNVERIFIED)
|
|
766
779
|
.option('--no-commit', 'skip the commit (tests only)')
|
|
767
780
|
.action(async (id, o) => {
|
|
768
781
|
claimedBy(o, id);
|
|
769
782
|
const r = await (
|
|
770
783
|
await ops()
|
|
771
|
-
).closeItem(process.cwd(), id, {
|
|
784
|
+
).closeItem(process.cwd(), id, { ...verifiedMove(o), constraints: o.constraint });
|
|
772
785
|
console.log(`closed ${r.id}${r.unplayed ? ' → unplayed' : ''}${shortHash(r.hash)}`);
|
|
773
786
|
});
|
|
774
787
|
}
|
|
@@ -982,6 +995,10 @@ function registerPlan(gcf) {
|
|
|
982
995
|
.action(async (id, o) => {
|
|
983
996
|
await touchSession(id);
|
|
984
997
|
const r = await (await lib('plan-check')).planCheck(process.cwd(), id);
|
|
998
|
+
// Harness-PRD §11: the checks a plan took to converge.
|
|
999
|
+
await (
|
|
1000
|
+
await lib('metrics')
|
|
1001
|
+
).logMetric(process.cwd(), 'plan-log.csv', [id, r.ok ? 1 : 0, r.problems?.length ?? 0]);
|
|
985
1002
|
out(o.json, r, (x) =>
|
|
986
1003
|
x.ok ? `${x.plan}: ${x.done}/${x.tasks} tasks done` : x.problems.join('\n'),
|
|
987
1004
|
);
|
|
@@ -1000,6 +1017,26 @@ function registerVerify(gcf) {
|
|
|
1000
1017
|
const r = o.mark
|
|
1001
1018
|
? await (await lib('integrate')).markVerified(process.cwd(), id)
|
|
1002
1019
|
: await (await lib('verify')).verifyItem(process.cwd(), id);
|
|
1020
|
+
// Methodology §6: a green DoD on main is what close and submit ask for, for this code.
|
|
1021
|
+
// A record git cannot write leaves the DoD unproven for the gate: the verify fails.
|
|
1022
|
+
if (r.ok && !o.mark)
|
|
1023
|
+
try {
|
|
1024
|
+
(await lib('verify-stamp')).stampVerified(process.cwd(), id);
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
r.ok = false;
|
|
1027
|
+
r.checks.push({ name: 'record', ok: false, problems: [e.message] });
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
// Harness-PRD §11: DoD attempts and what failed them.
|
|
1031
|
+
const failed = r.checks.filter((c) => !c.ok).map((c) => c.name);
|
|
1032
|
+
await (
|
|
1033
|
+
await lib('metrics')
|
|
1034
|
+
).logMetric(process.cwd(), 'verify-log.csv', [
|
|
1035
|
+
id,
|
|
1036
|
+
r.ok ? 1 : 0,
|
|
1037
|
+
failed.join(' '),
|
|
1038
|
+
o.mark ? 1 : 0,
|
|
1039
|
+
]);
|
|
1003
1040
|
out(o.json, r, (x) =>
|
|
1004
1041
|
[
|
|
1005
1042
|
...x.checks.flatMap((c) =>
|
package/extension.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://raw.githubusercontent.com/lee-to/ai-factory/2.x/schemas/extension.schema.json",
|
|
3
3
|
"name": "gcf-aif-ext",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.14",
|
|
5
5
|
"description": "Game Context Framework for AI Factory",
|
|
6
6
|
"skills": [
|
|
7
7
|
"skills/gcf-init",
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
## GCF: work item status after commit (from gcf-aif-ext)
|
|
2
|
-
|
|
3
|
-
gcf_commit:
|
|
4
|
-
-
|
|
5
|
-
-
|
|
6
|
-
- gate `
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
1
|
+
## GCF: work item status after commit (from gcf-aif-ext)
|
|
2
|
+
|
|
3
|
+
gcf_commit:
|
|
4
|
+
- on a `work/<id>` branch (a harness worktree) make none of the moves below: the session marks the branch with `ai-factory gcf verify <item-id> --mark` once its work is done, a stop goes into `.gcf-session/outcome.json`, and `ai-factory gcf integrate` on main records the move
|
|
5
|
+
- when `gcf/` exists and the commit implements a work item, commit the implementation first, then move the item with its own command in a follow-up commit
|
|
6
|
+
- gate `verdict` or `decision`: run `ai-factory gcf submit <item-id>` — it waits for the operator, `since:` records where the wait began
|
|
7
|
+
- gate `auto`, every check green: write the item's `live:` delta, then run `ai-factory gcf close <item-id>` — never edit `status:` by hand
|
|
8
|
+
- `submit` and `close` refuse code that changed since its last green `ai-factory gcf verify <item-id>`: run it again after any fix; `--unverified` is the human's call
|
|
9
|
+
- the work cannot go on: run `ai-factory gcf block <item-id> --reason "<why or the question>"`
|
|
10
|
+
- do not write a terminal status into an item file: a terminal move deletes the file and writes the history or graveyard line itself
|
|
11
|
+
- when the commit makes the tests of a hard unit still in `gcf/design/` green, run `ai-factory gcf done <id>` after it
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
gcf_gate:
|
|
4
4
|
- when `gcf/` exists and the task names a work item, run `ai-factory gcf context <item-id>` first
|
|
5
|
-
- the returned file list is the readset: read those files and nothing else under `gcf/`; `gcf/LIVE.md` in it says what the game already does
|
|
5
|
+
- the returned file list is the readset: read those files, `gcf/INDEX.md` and `gcf/config.yml`, and nothing else under `gcf/`; `gcf/LIVE.md` in it says what the game already does
|
|
6
|
+
- its `graveyard_hits` and `d2` lines are context too: what was buried before, and the assumptions in force with their `until:`
|
|
6
7
|
- `over_budget: true` in the result: stop and record a D1 question instead of planning
|
|
7
8
|
- an id in `depends:` that lives in `gcf/design/`, or as an item in any status but unplayed, parked or awaiting-verdict, is not played: do not plan on top of it, stop
|
|
8
9
|
- a dependency awaiting its verdict is planned on: the close of this item then waits for that verdict (`ai-factory gcf block <id> --reason "waits for <dep>"`)
|
|
@@ -6,3 +6,4 @@ gcf_review:
|
|
|
6
6
|
- check the diff against each pillar's review question: a contradiction is a finding, not a note
|
|
7
7
|
- numeric feel values in code outside the item's envelope: report the value and the envelope bounds
|
|
8
8
|
- a capability the diff adds, changes or removes that the item's `live:` delta does not name: a finding
|
|
9
|
+
- a feature child with `live: none: parent` carries no delta of its own: check the capability against its parent feature's `live:`, and one the parent does not name is a finding on the parent
|
package/lib/anchors.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, rm } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
@@ -230,3 +230,60 @@ export async function rewriteAnchors(projectDir, renamed) {
|
|
|
230
230
|
|
|
231
231
|
return changed;
|
|
232
232
|
}
|
|
233
|
+
|
|
234
|
+
/** `<base>.import.json`: the anchor map an import that has not finished started from. */
|
|
235
|
+
const heldFile = (projectDir, base) =>
|
|
236
|
+
path.join(path.resolve(projectDir), 'gcf', 'source', `${base}.import.json`);
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* `gcf anchor`: copy a GDD into gcf/source/, write its anchor map, and report its sections
|
|
240
|
+
* with their bodies and the delta. The delta counts from the map the last FINISHED import left:
|
|
241
|
+
* the map an import starts from is held until `finishImport`, so an import that died after
|
|
242
|
+
* anchoring reports the same delta again instead of an empty one (`resumed`).
|
|
243
|
+
*/
|
|
244
|
+
export async function anchorImport(projectDir, file) {
|
|
245
|
+
const base = path.basename(file);
|
|
246
|
+
const held = heldFile(projectDir, base);
|
|
247
|
+
const resumed = existsSync(held);
|
|
248
|
+
const current = await loadAnchorMap(projectDir, base);
|
|
249
|
+
const start = resumed
|
|
250
|
+
? JSON.parse(await readFile(held, 'utf8'))
|
|
251
|
+
: (current ?? { anchors: null, rebaselined: false });
|
|
252
|
+
// Read first: a wrong path must leave no held map behind to fake an unfinished import.
|
|
253
|
+
const sections = splitSections(await readFile(file, 'utf8'));
|
|
254
|
+
if (!resumed) {
|
|
255
|
+
await mkdir(path.dirname(held), { recursive: true });
|
|
256
|
+
await writeFile(held, `${JSON.stringify(start, null, 2)}\n`, 'utf8');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const delta = start.anchors ? classifyDelta(start.anchors, sections) : null;
|
|
260
|
+
await importSource(projectDir, file);
|
|
261
|
+
// A renamed heading moves its anchor: provenance and source: follow it, from the anchors
|
|
262
|
+
// they cite now - those of the last anchoring - and from the held start's too, for a file a
|
|
263
|
+
// rewrite that died left behind; a rename already applied finds nothing to change.
|
|
264
|
+
const moved = current?.anchors ? classifyDelta(current.anchors, sections).renamed : [];
|
|
265
|
+
const renamed = [...moved, ...(delta?.renamed ?? [])];
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
file: `gcf/source/${base}`,
|
|
269
|
+
sections: sections.map(({ anchor, heading, lines, body }) => ({
|
|
270
|
+
anchor,
|
|
271
|
+
heading,
|
|
272
|
+
lines,
|
|
273
|
+
body,
|
|
274
|
+
})),
|
|
275
|
+
delta,
|
|
276
|
+
rebaselined: Boolean(start.rebaselined),
|
|
277
|
+
resumed,
|
|
278
|
+
rewritten: await rewriteAnchors(projectDir, renamed),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** `gcf anchor <file> --done`: the import is written; the next delta counts from it. */
|
|
283
|
+
export async function finishImport(projectDir, file) {
|
|
284
|
+
const held = heldFile(projectDir, path.basename(file));
|
|
285
|
+
if (!existsSync(held)) return false;
|
|
286
|
+
await rm(held);
|
|
287
|
+
|
|
288
|
+
return true;
|
|
289
|
+
}
|
package/lib/board.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// `gcf board --json` (Methodology §11): everything a board shows, in one read. The board is a
|
|
2
2
|
// view: it shows these data and runs the same commands as the console; nothing here is stored.
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
3
4
|
import { readFile } from 'node:fs/promises';
|
|
4
5
|
import { existsSync } from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
@@ -14,6 +15,7 @@ import { wipOf } from './audit-core.js';
|
|
|
14
15
|
import { runHolder } from './main-lock.js';
|
|
15
16
|
import { ITEM_TYPES } from './validate-item.js';
|
|
16
17
|
import { movesOf } from './transitions.js';
|
|
18
|
+
import { openDepends } from './work-ops.js';
|
|
17
19
|
import { listQuestions } from './questions.js';
|
|
18
20
|
import { sessionActive } from './session-mark.js';
|
|
19
21
|
import { agentOf } from './agent-permissions.js';
|
|
@@ -90,6 +92,30 @@ const graveyardOf = async (root) =>
|
|
|
90
92
|
};
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* The commit time of each `since` hash, by hash: one git call for the whole board. A hash git
|
|
97
|
+
* does not know (rebased away), a short one (the core writes it whole) or text that is no hash
|
|
98
|
+
* gets no time.
|
|
99
|
+
*/
|
|
100
|
+
function commitTimes(projectDir, hashes) {
|
|
101
|
+
const asked = [...new Set(hashes.filter((h) => /^([0-9a-f]{40}|[0-9a-f]{64})$/.test(h ?? '')))];
|
|
102
|
+
if (!asked.length) return new Map();
|
|
103
|
+
const res = spawnSync(
|
|
104
|
+
'git',
|
|
105
|
+
['log', '--no-walk=unsorted', '--ignore-missing', '--format=%H %cI', ...asked],
|
|
106
|
+
{ cwd: projectDir, encoding: 'utf8' },
|
|
107
|
+
);
|
|
108
|
+
const found = (res.status === 0 ? res.stdout : '').split('\n').filter(Boolean);
|
|
109
|
+
|
|
110
|
+
return new Map(
|
|
111
|
+
asked.flatMap((h) => {
|
|
112
|
+
const line = found.find((l) => l.split(' ')[0] === h);
|
|
113
|
+
|
|
114
|
+
return line ? [[h, line.split(' ')[1]]] : [];
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
/** Verified branches; none outside a repository. */
|
|
94
120
|
function verifiedOf(projectDir) {
|
|
95
121
|
try {
|
|
@@ -179,8 +205,15 @@ function inRunOrder(items) {
|
|
|
179
205
|
|
|
180
206
|
const LISTS = ['children', 'system', 'depends'];
|
|
181
207
|
|
|
208
|
+
/** `run`, `session` or null. A harness run's lock wins over the mark its run on main holds. */
|
|
209
|
+
function runningKind(projectDir, id) {
|
|
210
|
+
if (runHolder(projectDir, id) !== null) return 'run';
|
|
211
|
+
|
|
212
|
+
return sessionActive(projectDir, id) ? 'session' : null;
|
|
213
|
+
}
|
|
214
|
+
|
|
182
215
|
/** One item of the board. */
|
|
183
|
-
async function itemOf(projectDir, c, verified) {
|
|
216
|
+
async function itemOf(projectDir, c, verified, ledger) {
|
|
184
217
|
const id = String(c.data.id);
|
|
185
218
|
// A trailer counts only while the item waits on main for its integrate.
|
|
186
219
|
const ready = c.data.status === 'ready' && verified.has(id);
|
|
@@ -188,7 +221,8 @@ async function itemOf(projectDir, c, verified) {
|
|
|
188
221
|
// is a run; a trailer is a passed DoD. A worktree with neither is a run that stopped (a
|
|
189
222
|
// timeout, a stop, a crash): the item is ready, and its next run resumes that worktree. A
|
|
190
223
|
// session the harness kept (`resume`) is resumed by the next run in either mode.
|
|
191
|
-
const
|
|
224
|
+
const kind = runningKind(projectDir, id);
|
|
225
|
+
const running = kind !== null;
|
|
192
226
|
const resume = c.data.status === 'ready' ? await resumeOf(projectDir, id) : null;
|
|
193
227
|
const resumable =
|
|
194
228
|
!running &&
|
|
@@ -206,9 +240,14 @@ async function itemOf(projectDir, c, verified) {
|
|
|
206
240
|
...(c.data.severity ? { severity: c.data.severity } : {}),
|
|
207
241
|
...(c.data.blocked ? { blocked: c.data.blocked } : {}),
|
|
208
242
|
running,
|
|
243
|
+
// `run`: a harness run's lock; `session`: an operator's or agent's session mark (`gcf claim`).
|
|
244
|
+
running_kind: kind,
|
|
209
245
|
resumable,
|
|
210
246
|
resume,
|
|
211
247
|
verified: ready,
|
|
248
|
+
// The dependencies the run gate would refuse a ready item on now (Board-PRD §5.2).
|
|
249
|
+
waits_for:
|
|
250
|
+
c.data.status === 'ready' ? openDepends(ledger, c.data.depends ?? [], { waiting: true }) : [],
|
|
212
251
|
// What a board may offer (§4.1): nothing while a run drives the item.
|
|
213
252
|
moves: running ? [] : movesOf(c.data),
|
|
214
253
|
file: `gcf/work/${c.name}`,
|
|
@@ -224,8 +263,14 @@ export async function board(projectDir) {
|
|
|
224
263
|
const ledger = await buildLedger(projectDir);
|
|
225
264
|
const verified = verifiedOf(projectDir);
|
|
226
265
|
const items = [];
|
|
227
|
-
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified));
|
|
266
|
+
for (const c of ledger.items) items.push(await itemOf(projectDir, c, verified, ledger));
|
|
228
267
|
inRunOrder(items);
|
|
268
|
+
// When the wait began: the time of the commit `since` names (Board-PRD §13).
|
|
269
|
+
const times = commitTimes(
|
|
270
|
+
projectDir,
|
|
271
|
+
items.map((i) => i.since),
|
|
272
|
+
);
|
|
273
|
+
for (const i of items) i.since_at = times.get(i.since) ?? null;
|
|
229
274
|
const inbox = await listInbox(root);
|
|
230
275
|
// Where agents struggle: the lessons in the inbox each item's sessions left.
|
|
231
276
|
for (const i of items) i.lessons = inbox.filter((e) => e.item === i.id).length;
|
package/lib/integrate.js
CHANGED
|
@@ -271,6 +271,9 @@ async function integrateOne(projectDir, id) {
|
|
|
271
271
|
* branch bringing its own lines could not merge over them. Union merge joins the lines.
|
|
272
272
|
*/
|
|
273
273
|
function commitMetrics(projectDir) {
|
|
274
|
+
// A log the harness started on main (run-log, guard-log) is new to git: add it first; only
|
|
275
|
+
// the logs, never an editor's swap file beside them.
|
|
276
|
+
git(projectDir, ['add', '--', 'gcf/.metrics/*.csv'], { check: false });
|
|
274
277
|
const dirty = git(projectDir, [
|
|
275
278
|
'status',
|
|
276
279
|
'--porcelain',
|
|
@@ -278,17 +281,23 @@ function commitMetrics(projectDir) {
|
|
|
278
281
|
'--',
|
|
279
282
|
'gcf/.metrics',
|
|
280
283
|
]);
|
|
284
|
+
// Best effort: a main mid-rebase or mid-merge keeps its logs for the next integrate, and a
|
|
285
|
+
// run still starts.
|
|
281
286
|
if (dirty.out)
|
|
282
|
-
git(
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
287
|
+
git(
|
|
288
|
+
projectDir,
|
|
289
|
+
[
|
|
290
|
+
'commit',
|
|
291
|
+
'-q',
|
|
292
|
+
'--no-verify',
|
|
293
|
+
'-m',
|
|
294
|
+
'gcf(metrics): run logs',
|
|
295
|
+
'--only',
|
|
296
|
+
'--',
|
|
297
|
+
'gcf/.metrics',
|
|
298
|
+
],
|
|
299
|
+
{ check: false },
|
|
300
|
+
);
|
|
292
301
|
}
|
|
293
302
|
|
|
294
303
|
/**
|
|
@@ -302,11 +311,13 @@ export async function integrate(projectDir, ids = []) {
|
|
|
302
311
|
throw new Error(`integrate runs in the main checkout on its branch, not on ${branch}`);
|
|
303
312
|
|
|
304
313
|
return withMainLock(projectDir, async () => {
|
|
305
|
-
//
|
|
314
|
+
// The run logs go in at every integrate, a main-mode run's first one too: nothing else
|
|
315
|
+
// commits them there. Nothing to merge leaves the rest of main alone: a run's first
|
|
316
|
+
// integrate must not refuse a dirty tree.
|
|
317
|
+
commitMetrics(projectDir);
|
|
306
318
|
if (!ids.length && !verifiedBranches(projectDir).length) return [];
|
|
307
319
|
if (git(projectDir, ['status', '--porcelain', '--untracked-files=no', '--', '.', METRICS]).out)
|
|
308
320
|
throw new Error('main has uncommitted changes: commit or stash them before integrating');
|
|
309
|
-
commitMetrics(projectDir);
|
|
310
321
|
for (const id of ids)
|
|
311
322
|
if (!isVerified(projectDir, id))
|
|
312
323
|
throw new Error(
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The run logs this module writes to gcf/.metrics (Harness-PRD §11): append-only csv, one
|
|
2
|
+
// line per call, merged by union. A worktree's lines go to the main checkout's logs: the
|
|
3
|
+
// worktree's own gcf/.metrics is thrown away when its branch merges.
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
/** gcf/.metrics of the main checkout of `projectDir`; its own outside a repository. */
|
|
10
|
+
export function metricsDir(projectDir) {
|
|
11
|
+
const res = spawnSync('git', ['rev-parse', '--git-common-dir'], {
|
|
12
|
+
cwd: projectDir,
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
});
|
|
15
|
+
const common = res.status === 0 ? path.resolve(projectDir, res.stdout.trim()) : '';
|
|
16
|
+
const main = path.basename(common) === '.git' ? path.dirname(common) : projectDir;
|
|
17
|
+
|
|
18
|
+
return path.join(main, 'gcf', '.metrics');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A field as csv takes it: no comma, no line break, at most 120 characters. */
|
|
22
|
+
const field = (v) =>
|
|
23
|
+
String(v ?? '')
|
|
24
|
+
.replace(/[,\r\n]+/g, ';')
|
|
25
|
+
.slice(0, 120);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Append one line to a log, timestamped; a project without gcf/ keeps no logs. Best effort: a
|
|
29
|
+
* log that cannot be written (a csv held open by a spreadsheet) never fails the command.
|
|
30
|
+
*/
|
|
31
|
+
export async function logMetric(projectDir, file, fields) {
|
|
32
|
+
try {
|
|
33
|
+
const dir = metricsDir(projectDir);
|
|
34
|
+
if (!existsSync(path.dirname(dir))) return;
|
|
35
|
+
await mkdir(dir, { recursive: true });
|
|
36
|
+
const line = [new Date().toISOString(), ...fields].map(field).join(',');
|
|
37
|
+
await appendFile(path.join(dir, file), `${line}\n`, 'utf8');
|
|
38
|
+
} catch {
|
|
39
|
+
// No line this time.
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// The record of a green `gcf verify` on main (Methodology §6): `close` and `submit` need one for
|
|
2
|
+
// the code as it is now, so a DoD run some other way cannot stand in for it. The record holds
|
|
3
|
+
// the hash of the project's code, not of a commit: committing the verified code keeps it valid,
|
|
4
|
+
// and any edit after the verify (a review's fix) asks for a new one. In a worktree the
|
|
5
|
+
// `GCF-Verified` trailer does this job, and the integrator's moves need no record.
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { copyFileSync, existsSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
// Not code: the framework's own files, the plans, the run files. A move or a plan tick there
|
|
12
|
+
// after the verify does not change what the DoD proved.
|
|
13
|
+
const NOT_CODE = ['gcf', '.ai-factory', '.gcf-session'];
|
|
14
|
+
|
|
15
|
+
const stampFile = (projectDir, id) =>
|
|
16
|
+
path.join(path.resolve(projectDir), '.gcf-session', 'runs', `${id}.verified`);
|
|
17
|
+
|
|
18
|
+
const git = (projectDir, args, env = {}) =>
|
|
19
|
+
spawnSync('git', args, { cwd: projectDir, encoding: 'utf8', env: { ...process.env, ...env } });
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The git tree of the project's code as it is on disk, committed or not, new files included
|
|
23
|
+
* (ignored ones are not); null outside a repository. A git that fails on the code (a file it
|
|
24
|
+
* cannot read, `core.safecrlf`) throws: a gate that cannot see the code must not let it pass. A
|
|
25
|
+
* copy of the index keeps git's stat cache, so only changed files are hashed again.
|
|
26
|
+
*/
|
|
27
|
+
export function codeTree(projectDir) {
|
|
28
|
+
const index = git(projectDir, ['rev-parse', '--git-path', 'index']);
|
|
29
|
+
if (index.status !== 0) return null;
|
|
30
|
+
const real = path.resolve(projectDir, index.stdout.trim());
|
|
31
|
+
const tmp = path.join(os.tmpdir(), `gcf-code-index-${process.pid}-${Date.now()}`);
|
|
32
|
+
const env = { GIT_INDEX_FILE: tmp };
|
|
33
|
+
const step = (args) => {
|
|
34
|
+
const r = git(projectDir, args, env);
|
|
35
|
+
if (r.status !== 0) throw new Error(`git ${args[0]} on the code failed: ${r.stderr.trim()}`);
|
|
36
|
+
|
|
37
|
+
return r.stdout.trim();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(real)) copyFileSync(real, tmp);
|
|
42
|
+
step(['add', '-A', '--', '.', ...NOT_CODE.map((p) => `:(exclude)${p}`)]);
|
|
43
|
+
step(['rm', '-r', '-q', '--cached', '--ignore-unmatch', '--', ...NOT_CODE]);
|
|
44
|
+
|
|
45
|
+
return step(['write-tree']);
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(tmp, { force: true });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Record a green verify of the code as it is now. */
|
|
52
|
+
export function stampVerified(projectDir, id) {
|
|
53
|
+
const tree = codeTree(projectDir);
|
|
54
|
+
if (!tree) return;
|
|
55
|
+
const file = stampFile(projectDir, id);
|
|
56
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
57
|
+
writeFileSync(file, `${JSON.stringify({ tree, at: new Date().toISOString() })}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A move ended the item's work: its record goes with it. */
|
|
61
|
+
export const dropStamp = (projectDir, id) => rmSync(stampFile(projectDir, id), { force: true });
|
|
62
|
+
|
|
63
|
+
/** The files that differ between two trees, the first few, for a refusal to name. */
|
|
64
|
+
function changed(projectDir, from, to) {
|
|
65
|
+
const r = git(projectDir, ['diff-tree', '-r', '--name-only', from, to]);
|
|
66
|
+
const files = r.status === 0 ? r.stdout.split('\n').filter(Boolean) : [];
|
|
67
|
+
const more = files.length > 5 ? ` and ${files.length - 5} more` : '';
|
|
68
|
+
|
|
69
|
+
return files.length ? ` (${files.slice(0, 5).join(', ')}${more})` : '';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Why `close` or `submit` may not take the item now, or null: no green verify on record, or
|
|
74
|
+
* the code changed since. A feature closes over its children and has no code of its own. The
|
|
75
|
+
* way past it is named as the human's: an agent reads this text.
|
|
76
|
+
*/
|
|
77
|
+
export function unverifiedProblem(projectDir, id, type) {
|
|
78
|
+
if (type === 'feature') return null;
|
|
79
|
+
const tree = codeTree(projectDir);
|
|
80
|
+
if (!tree) return null;
|
|
81
|
+
const how =
|
|
82
|
+
`run ai-factory gcf verify ${id} until it is green; only the human may move it without ` +
|
|
83
|
+
'one (--unverified "<why>")';
|
|
84
|
+
const stamp = readStamp(projectDir, id);
|
|
85
|
+
if (!stamp) return `${id}: no green gcf verify on record — ${how}`;
|
|
86
|
+
if (stamp.tree !== tree) {
|
|
87
|
+
const files = changed(projectDir, stamp.tree, tree);
|
|
88
|
+
|
|
89
|
+
return `${id}: the code changed since its green gcf verify${files} — ${how}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @returns {{tree: string, at: string} | null} the record, or null when there is none. */
|
|
96
|
+
function readStamp(projectDir, id) {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(readFileSync(stampFile(projectDir, id), 'utf8'));
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/work-ops.js
CHANGED
|
@@ -39,6 +39,7 @@ import { dropInbox } from './inbox.js';
|
|
|
39
39
|
import { refuseByPhase } from './phase.js';
|
|
40
40
|
import { locked, runHolder } from './main-lock.js';
|
|
41
41
|
import { dropAgentSession, releaseSession, sessionActive } from './session-mark.js';
|
|
42
|
+
import { dropStamp, unverifiedProblem } from './verify-stamp.js';
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Options of a committed move; each move reads the ones it needs.
|
|
@@ -232,10 +233,38 @@ async function finish(m, move, tail, commit) {
|
|
|
232
233
|
// and no rerun resumes it.
|
|
233
234
|
releaseSession(m.projectDir, String(m.item.data.id));
|
|
234
235
|
dropAgentSession(m.projectDir, String(m.item.data.id));
|
|
236
|
+
dropStamp(m.projectDir, String(m.item.data.id));
|
|
235
237
|
|
|
236
238
|
return hash;
|
|
237
239
|
}
|
|
238
240
|
|
|
241
|
+
/**
|
|
242
|
+
* `close` and `submit` from a command (Methodology §6): the code as it is now needs a green
|
|
243
|
+
* `gcf verify`, unless the operator says why not (`unverified`, written into the move's
|
|
244
|
+
* commit). The integrator's moves rest on the branch's trailer instead.
|
|
245
|
+
*/
|
|
246
|
+
function needVerified(m, opts) {
|
|
247
|
+
if (!opts.requireVerify || opts.unverified) return;
|
|
248
|
+
const id = String(m.item.data.id);
|
|
249
|
+
const problem = verifyGateProblem(m.projectDir, id, m.item.data.type);
|
|
250
|
+
if (problem) throw new Error(problem);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The verify gate's refusal, or null; a git that cannot hash the code refuses too. */
|
|
254
|
+
function verifyGateProblem(projectDir, id, type) {
|
|
255
|
+
try {
|
|
256
|
+
return unverifiedProblem(projectDir, id, type);
|
|
257
|
+
} catch (e) {
|
|
258
|
+
return (
|
|
259
|
+
`${id}: cannot hash the code for the verify gate (${e.message}) — fix that, or the ` +
|
|
260
|
+
'human passes --unverified "<why>"'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The commit's tail of a move made without a green verify. */
|
|
266
|
+
const unverifiedTail = (opts) => (opts.unverified ? ` (unverified: ${opts.unverified})` : '');
|
|
267
|
+
|
|
239
268
|
const writeItem = (item, data, body = item.body) => atomicWrite(item.file, serialize(data, body));
|
|
240
269
|
|
|
241
270
|
/** `data` without the keys named — a field that no longer applies leaves the frontmatter. */
|
|
@@ -481,11 +510,12 @@ async function submitItemUnlocked(projectDir, id, opts = {}) {
|
|
|
481
510
|
if (m.item.data.gate === 'auto')
|
|
482
511
|
throw new Error(`${id} has gate auto: nobody waits for it — close it with gcf close ${id}`);
|
|
483
512
|
refuseOpenDepends(m, 'submit', { waiting: true });
|
|
513
|
+
needVerified(m, opts);
|
|
484
514
|
const status = m.item.data.gate === 'verdict' ? 'awaiting-verdict' : 'awaiting-decision';
|
|
485
515
|
const data = { ...without(m.item.data, 'blocked'), status, since: headHash(projectDir) };
|
|
486
516
|
m.step(() => writeItem(m.item, data));
|
|
487
517
|
|
|
488
|
-
return { id, status, hash: await finish(m, 'submit', id
|
|
518
|
+
return { id, status, hash: await finish(m, 'submit', `${id}${unverifiedTail(opts)}`, commit) };
|
|
489
519
|
}
|
|
490
520
|
|
|
491
521
|
/** Why a soft gate-auto item cannot close unplayed, or null: only an `added` comes back out. */
|
|
@@ -556,11 +586,13 @@ async function closeItemUnlocked(projectDir, id, opts = {}) {
|
|
|
556
586
|
if (m.item.data.gate !== 'auto')
|
|
557
587
|
throw new Error(`${id} has gate ${m.item.data.gate}: submit it, the operator closes it`);
|
|
558
588
|
refuseOpenDepends(m, 'close');
|
|
589
|
+
needVerified(m, opts);
|
|
559
590
|
if (unplayed) closeUnplayed(m);
|
|
560
591
|
else await closeEnding(m, constraints);
|
|
561
592
|
}
|
|
562
593
|
|
|
563
|
-
const
|
|
594
|
+
const tail = `${id}${unplayed ? ' → unplayed' : ''}${unverifiedTail(opts)}`;
|
|
595
|
+
const hash = await finish(m, 'close', tail, commit);
|
|
564
596
|
|
|
565
597
|
return { id, hash, unplayed };
|
|
566
598
|
}
|
package/package.json
CHANGED
package/profiles/pillars.md
CHANGED
|
@@ -25,7 +25,7 @@ sections:
|
|
|
25
25
|
note: pillar count <= 7 and the token budget are enforced by the gcf audit core; id is the slug a design unit cites in pillars:
|
|
26
26
|
|
|
27
27
|
custom_sections:
|
|
28
|
-
- none: a pillar that needs more than its
|
|
28
|
+
- none: a pillar that needs more than its statement, invariant, review question and id is not a pillar yet
|
|
29
29
|
|
|
30
30
|
enforcement:
|
|
31
31
|
- `npx --yes cnlp-kit check`, over the file a `cnlp.config.json` corpus entry maps to this profile
|