gcf-aif-ext 0.2.4 → 0.2.7
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 +4 -2
- package/commands/gcf.js +267 -58
- package/extension.json +1 -1
- package/hooks/pre-commit.js +28 -14
- package/injections/review-pillars.md +1 -1
- package/lib/anchors.js +108 -10
- package/lib/audit-core.js +136 -41
- package/lib/context.js +54 -36
- package/lib/frontmatter.js +2 -1
- package/lib/gcf-paths.js +14 -3
- package/lib/git.js +22 -4
- package/lib/id.js +2 -0
- package/lib/index-gen.js +33 -15
- package/lib/linter.js +79 -41
- package/lib/safe-path.js +23 -4
- package/lib/transitions.js +1 -3
- package/lib/validate-card.js +42 -8
- package/lib/verdict-fs.js +124 -61
- package/package.json +1 -1
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-card/SKILL.md +11 -10
- package/skills/gcf-debate/SKILL.md +1 -1
- package/skills/gcf-ingest/SKILL.md +24 -13
- package/skills/gcf-init/SKILL.md +9 -8
- package/skills/gcf-verdict/SKILL.md +1 -1
- package/templates/backlog.md +4 -3
package/lib/id.js
CHANGED
|
@@ -11,6 +11,7 @@ export function slugify(name) {
|
|
|
11
11
|
.replace(/[^a-z0-9]+/g, '-')
|
|
12
12
|
.replace(/^-+|-+$/g, '');
|
|
13
13
|
if (!slug) throw new Error(`Cannot derive a slug from: ${JSON.stringify(name)}`);
|
|
14
|
+
|
|
14
15
|
return slug;
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -22,6 +23,7 @@ export function isValidId(id) {
|
|
|
22
23
|
export function candToCard(candId) {
|
|
23
24
|
const m = String(candId).match(/^cand-(\d{3})$/);
|
|
24
25
|
if (!m) throw new Error(`Not a candidate id: ${candId}`);
|
|
26
|
+
|
|
25
27
|
return `card-${m[1]}`;
|
|
26
28
|
}
|
|
27
29
|
|
package/lib/index-gen.js
CHANGED
|
@@ -10,36 +10,53 @@ import { atomicWrite } from './safe-path.js';
|
|
|
10
10
|
*/
|
|
11
11
|
export async function generate(root) {
|
|
12
12
|
const lines = ['# INDEX — generated, do not edit', ''];
|
|
13
|
-
const row = (type, file, status, system, descr) =>
|
|
13
|
+
const row = ([type, file, status, system, descr]) =>
|
|
14
14
|
lines.push(`${type} | gcf/${file} | ${status} | ${system} | ${descr}`);
|
|
15
15
|
|
|
16
|
-
if (existsSync(path.join(root, 'PILLARS.md')))
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
if (existsSync(path.join(root, 'PILLARS.md')))
|
|
17
|
+
row(['pillars', 'PILLARS.md', '-', '-', 'pillars and negative invariants']);
|
|
18
|
+
const backlog = await readIfExists(root, 'backlog.md');
|
|
19
|
+
if (backlog !== null) {
|
|
20
|
+
row(['backlog', 'backlog.md', '-', '-', `${count(backlog, /^- cand-/gm)} candidates`]);
|
|
20
21
|
}
|
|
22
|
+
|
|
21
23
|
for (const s of await listSystems(root)) {
|
|
22
|
-
|
|
24
|
+
const purpose = firstBullet(s.body, 'purpose');
|
|
25
|
+
row(['system', `systems/${s.name}`, '-', s.data.system ?? '-', purpose]);
|
|
23
26
|
}
|
|
27
|
+
|
|
24
28
|
for (const c of await listCards(root)) {
|
|
25
|
-
|
|
29
|
+
const system = (c.data.system ?? []).join(',');
|
|
30
|
+
row(['card', `cards/${c.name}`, c.data.status ?? '?', system, firstBullet(c.body, 'behavior')]);
|
|
26
31
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
|
|
33
|
+
const graveyard = await readIfExists(root, 'graveyard.md');
|
|
34
|
+
if (graveyard !== null) {
|
|
35
|
+
row(['graveyard', 'graveyard.md', '-', '-', `${count(graveyard, /^- card-/gm)} killed ideas`]);
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
|
|
37
|
+
|
|
38
|
+
const txt = await readIfExists(root, 'open-questions.md');
|
|
39
|
+
if (txt !== null) {
|
|
40
|
+
const d1 = count(txt, /^\s*class:\s*D1/gm);
|
|
41
|
+
const d2 = count(txt, /^\s*class:\s*D2/gm);
|
|
42
|
+
row(['questions', 'open-questions.md', '-', '-', `${d1} D1, ${d2} D2`]);
|
|
36
43
|
}
|
|
44
|
+
|
|
37
45
|
return `${lines.join('\n')}\n`;
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
async function readIfExists(root, rel) {
|
|
49
|
+
const file = path.join(root, rel);
|
|
50
|
+
|
|
51
|
+
return existsSync(file) ? readFile(file, 'utf8') : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const count = (text, re) => (text.match(re) ?? []).length;
|
|
55
|
+
|
|
40
56
|
export async function writeIndex(root) {
|
|
41
57
|
const content = await generate(root);
|
|
42
58
|
await atomicWrite(path.join(root, 'INDEX.md'), content);
|
|
59
|
+
|
|
43
60
|
return content;
|
|
44
61
|
}
|
|
45
62
|
|
|
@@ -47,5 +64,6 @@ export async function writeIndex(root) {
|
|
|
47
64
|
export async function drifted(root) {
|
|
48
65
|
const file = path.join(root, 'INDEX.md');
|
|
49
66
|
if (!existsSync(file)) return true;
|
|
67
|
+
|
|
50
68
|
return (await readFile(file, 'utf8')) !== (await generate(root));
|
|
51
69
|
}
|
package/lib/linter.js
CHANGED
|
@@ -5,6 +5,15 @@
|
|
|
5
5
|
const UNIT_NUM = /\d+(?:[.,]\d+)?\s?(?:ms|мс|s|с|dmg|hp|px|сек)\b/;
|
|
6
6
|
const SIGNATURE = /\w+\s*\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
|
|
7
7
|
const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
|
|
8
|
+
const ENVELOPE_FIELD = /^\s*(?:- )?(?:question|assumed|proposed):/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} LintState
|
|
12
|
+
* @property {string[]} lines
|
|
13
|
+
* @property {Array<{line: number, rule: string, text: string}>} issues
|
|
14
|
+
* @property {Array<{line: number, reason: string | null}>} pragmas
|
|
15
|
+
* @property {number} fenceStart line index of the open fence, -1 when outside one
|
|
16
|
+
*/
|
|
8
17
|
|
|
9
18
|
/** Which `key:` block a line sits in (flat CNL-P body). */
|
|
10
19
|
function blockOf(lines, i) {
|
|
@@ -12,50 +21,79 @@ function blockOf(lines, i) {
|
|
|
12
21
|
const m = lines[j].match(/^([a-z_]+):\s*$/) ?? lines[j].match(/^([a-z_]+):\s+\S/);
|
|
13
22
|
if (m) return m[1];
|
|
14
23
|
}
|
|
24
|
+
|
|
15
25
|
return null;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export function lint(content) {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
let
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
29
|
+
/** @type {LintState} */
|
|
30
|
+
const st = { lines: String(content).split('\n'), issues: [], pragmas: [], fenceStart: -1 };
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < st.lines.length; i++) {
|
|
33
|
+
const allowed = readPragma(st, i);
|
|
34
|
+
if (st.lines[i].startsWith('```')) toggleFence(st, i);
|
|
35
|
+
else if (st.fenceStart < 0 && !allowed) checkLine(st, i);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// An unclosed fence leaves fenceStart set, so the loop above skipped every remaining
|
|
39
|
+
// line: one stray fence would otherwise disable П2 for the rest of the file.
|
|
40
|
+
if (st.fenceStart >= 0)
|
|
41
|
+
st.issues.push({
|
|
42
|
+
line: st.fenceStart + 1,
|
|
43
|
+
rule: 'unterminated-fence',
|
|
44
|
+
text: 'code fence never closed',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return { issues: st.issues, pragmas: st.pragmas };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Records a pragma on the previous line; true when it carries a reason and so allows line i. */
|
|
51
|
+
function readPragma(st, i) {
|
|
52
|
+
const prev = st.lines[i - 1] ?? '';
|
|
53
|
+
const pragma = prev.match(/<!--\s*gcf:allow\s*(.*?)\s*-->/);
|
|
54
|
+
if (!pragma) return false;
|
|
55
|
+
if (!pragma[1]) st.issues.push({ line: i, rule: 'pragma-without-reason', text: prev.trim() });
|
|
56
|
+
st.pragmas.push({ line: i, reason: pragma[1] || null });
|
|
57
|
+
|
|
58
|
+
return Boolean(pragma[1]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Opens a fence, or closes it and flags a body over 3 lines. */
|
|
62
|
+
function toggleFence(st, i) {
|
|
63
|
+
if (st.fenceStart < 0) {
|
|
64
|
+
st.fenceStart = i;
|
|
65
|
+
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const size = i - st.fenceStart - 1;
|
|
70
|
+
if (size > 3)
|
|
71
|
+
st.issues.push({
|
|
72
|
+
line: st.fenceStart + 1,
|
|
73
|
+
rule: 'fenced-code-over-3-lines',
|
|
74
|
+
text: `${size} lines`,
|
|
75
|
+
});
|
|
76
|
+
st.fenceStart = -1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const memberBullet = (line) => /^- /.test(line) && CAMEL.test(line);
|
|
80
|
+
|
|
81
|
+
/** Pattern rules for one prose line (outside fences, not pragma-allowed). */
|
|
82
|
+
function checkLine(st, i) {
|
|
83
|
+
const line = st.lines[i];
|
|
84
|
+
const flag = (rule) => st.issues.push({ line: i + 1, rule, text: line.trim() });
|
|
85
|
+
|
|
86
|
+
// Envelopes are legal where the spec puts them: the feel block, and the question/assumed/
|
|
87
|
+
// proposed fields of an open-questions record, where import parks D2 envelopes for the human.
|
|
88
|
+
if (blockOf(st.lines, i) !== 'feel' && !ENVELOPE_FIELD.test(line) && UNIT_NUM.test(line)) {
|
|
89
|
+
flag('number-with-unit-outside-feel');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (SIGNATURE.test(line)) flag('signature');
|
|
93
|
+
|
|
94
|
+
// Three or more consecutive bullets each naming CamelCase identifiers = a member list.
|
|
95
|
+
const prev = st.lines[i - 1] ?? '';
|
|
96
|
+
if (memberBullet(line) && memberBullet(prev) && memberBullet(st.lines[i - 2] ?? '')) {
|
|
97
|
+
flag('camelcase-member-list');
|
|
56
98
|
}
|
|
57
|
-
// An unclosed fence leaves fenceStart set, so the loop above `continue`d past every
|
|
58
|
-
// remaining line: one stray fence would otherwise disable П2 for the rest of the file.
|
|
59
|
-
if (fenceStart >= 0) issues.push({ line: fenceStart + 1, rule: 'unterminated-fence', text: 'code fence never closed' });
|
|
60
|
-
return { issues, pragmas };
|
|
61
99
|
}
|
package/lib/safe-path.js
CHANGED
|
@@ -21,6 +21,7 @@ export function resolveInside(projectDir, target) {
|
|
|
21
21
|
' next: pass a path within the project root',
|
|
22
22
|
);
|
|
23
23
|
}
|
|
24
|
+
|
|
24
25
|
return resolved;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -39,9 +40,21 @@ export async function atomicWrite(file, data) {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
|
|
43
|
+
/** Put one snapshotted file back: `null` content means it did not exist before. */
|
|
44
|
+
async function restoreSnapshot(file, content) {
|
|
45
|
+
if (content === null)
|
|
46
|
+
await unlink(file).catch((e) => {
|
|
47
|
+
if (e.code !== 'ENOENT') throw e;
|
|
48
|
+
});
|
|
49
|
+
else await atomicWrite(file, content);
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
/**
|
|
43
53
|
* Restore every listed file to its original bytes when `action` fails. With `gitCwd` (an
|
|
44
54
|
* existing directory in the work tree) the files' git index entries are restored as well.
|
|
55
|
+
* @param {string[]} files
|
|
56
|
+
* @param {() => Promise<any>} action
|
|
57
|
+
* @param {{ gitCwd?: string | null }} [options]
|
|
45
58
|
*/
|
|
46
59
|
export async function withFileRollback(files, action, { gitCwd = null } = {}) {
|
|
47
60
|
const unique = [...new Set(files)];
|
|
@@ -62,16 +75,22 @@ export async function withFileRollback(files, action, { gitCwd = null } = {}) {
|
|
|
62
75
|
const rollbackErrors = [];
|
|
63
76
|
for (const [file, content] of snapshots) {
|
|
64
77
|
try {
|
|
65
|
-
|
|
66
|
-
else await atomicWrite(file, content);
|
|
78
|
+
await restoreSnapshot(file, content);
|
|
67
79
|
} catch (rollbackError) {
|
|
68
80
|
rollbackErrors.push(rollbackError);
|
|
69
81
|
}
|
|
70
82
|
}
|
|
83
|
+
|
|
71
84
|
// An inner step may already have staged its writes. Re-adding the restored files would
|
|
72
|
-
// fold the user's unstaged edits into the index; putting back the old entries keeps
|
|
85
|
+
// fold the user's unstaged edits into the index; putting back the old entries keeps
|
|
86
|
+
// them apart.
|
|
73
87
|
rollbackErrors.push(...restoreIndex(index));
|
|
74
|
-
if (rollbackErrors.length)
|
|
88
|
+
if (rollbackErrors.length)
|
|
89
|
+
throw new AggregateError(
|
|
90
|
+
[err, ...rollbackErrors],
|
|
91
|
+
`Operation failed and rollback was incomplete: ${err.message}`,
|
|
92
|
+
{ cause: err },
|
|
93
|
+
);
|
|
75
94
|
throw err;
|
|
76
95
|
}
|
|
77
96
|
}
|
package/lib/transitions.js
CHANGED
|
@@ -19,9 +19,7 @@ export function isLegal(from, to) {
|
|
|
19
19
|
|
|
20
20
|
/** Statuses reachable from `from`, for error messages. */
|
|
21
21
|
export function legalTargets(from) {
|
|
22
|
-
return [...LEGAL]
|
|
23
|
-
.filter((t) => t.startsWith(`${from}>`))
|
|
24
|
-
.map((t) => t.split('>')[1]);
|
|
22
|
+
return [...LEGAL].filter((t) => t.startsWith(`${from}>`)).map((t) => t.split('>')[1]);
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
/** A status a card FILE may carry. Terminal statuses exist only in the transition itself. */
|
package/lib/validate-card.js
CHANGED
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
// Runtime mirror of schemas/card.schema.json for the pre-commit hook (no ajv in
|
|
2
2
|
// production deps). The parity test keeps the two in agreement.
|
|
3
|
-
const FIELDS = [
|
|
3
|
+
const FIELDS = [
|
|
4
|
+
'schema',
|
|
5
|
+
'id',
|
|
6
|
+
'slug',
|
|
7
|
+
'status',
|
|
8
|
+
'route',
|
|
9
|
+
'system',
|
|
10
|
+
'depends',
|
|
11
|
+
'source',
|
|
12
|
+
'tune_count',
|
|
13
|
+
'built_at',
|
|
14
|
+
'created',
|
|
15
|
+
];
|
|
4
16
|
const REQUIRED = FIELDS.filter((f) => f !== 'built_at');
|
|
5
17
|
|
|
6
18
|
const RULES = {
|
|
7
19
|
schema: (v) => v === 1 || 'schema must be 1',
|
|
8
20
|
id: (v) => /^card-\d{3}$/.test(v) || 'id must match card-NNN',
|
|
9
21
|
slug: (v) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(v) || 'slug must be a lowercase hyphen slug',
|
|
10
|
-
status: (v) =>
|
|
22
|
+
status: (v) =>
|
|
23
|
+
['proposed', 'built', 'tuning'].includes(v) ||
|
|
24
|
+
`status "${v}" is not storable (П1: terminal statuses never live in a file)`,
|
|
11
25
|
route: (v) => ['soft', 'hard'].includes(v) || 'route must be soft or hard',
|
|
12
|
-
system: (v) =>
|
|
13
|
-
|
|
14
|
-
|
|
26
|
+
system: (v) =>
|
|
27
|
+
(Array.isArray(v) && v.length >= 1 && v.every((s) => /^[a-z0-9]+(-[a-z0-9]+)*$/.test(s))) ||
|
|
28
|
+
'system must list >=1 subsystem slugs',
|
|
29
|
+
depends: (v) =>
|
|
30
|
+
(Array.isArray(v) && v.every((d) => /^card-\d{3}$/.test(d))) ||
|
|
31
|
+
'depends must list card-NNN ids',
|
|
32
|
+
source: (v) =>
|
|
33
|
+
/^(GDD#[A-Za-z0-9_-]+|manual|inferred)$/.test(v) ||
|
|
34
|
+
'source must be GDD#<id>, manual or inferred',
|
|
15
35
|
tune_count: (v) => (Number.isInteger(v) && v >= 0) || 'tune_count must be an integer >= 0',
|
|
16
|
-
built_at: (v) =>
|
|
36
|
+
built_at: (v) =>
|
|
37
|
+
v === null ||
|
|
38
|
+
v === undefined ||
|
|
39
|
+
/^[0-9a-f]{7,40}$/.test(v) ||
|
|
40
|
+
'built_at must be null or a commit hash',
|
|
17
41
|
created: (v) => /^\d{4}-\d{2}-\d{2}$/.test(dateStr(v)) || 'created must be YYYY-MM-DD',
|
|
18
42
|
};
|
|
19
43
|
|
|
@@ -26,12 +50,17 @@ export function validateCard(data) {
|
|
|
26
50
|
const errors = [];
|
|
27
51
|
for (const key of REQUIRED) if (!(key in data)) errors.push(`missing field: ${key}`);
|
|
28
52
|
for (const key of Object.keys(data)) {
|
|
29
|
-
if (!FIELDS.includes(key))
|
|
53
|
+
if (!FIELDS.includes(key))
|
|
54
|
+
errors.push(
|
|
55
|
+
`unknown field: ${key}` +
|
|
56
|
+
(key === 'until' ? ' (until: is a line annotation, not frontmatter)' : ''),
|
|
57
|
+
);
|
|
30
58
|
else if (key in RULES) {
|
|
31
59
|
const res = RULES[key](data[key]);
|
|
32
60
|
if (res !== true) errors.push(res);
|
|
33
61
|
}
|
|
34
62
|
}
|
|
63
|
+
|
|
35
64
|
return errors;
|
|
36
65
|
}
|
|
37
66
|
|
|
@@ -41,8 +70,13 @@ export function validateUntil(body) {
|
|
|
41
70
|
const lines = String(body).split('\n');
|
|
42
71
|
lines.forEach((line, i) => {
|
|
43
72
|
for (const m of line.matchAll(/\[until:[^\]]*\]/g)) {
|
|
44
|
-
if (!UNTIL_OK.test(m[0]))
|
|
73
|
+
if (!UNTIL_OK.test(m[0]))
|
|
74
|
+
errors.push(
|
|
75
|
+
`line ${i + 1}: bad until annotation ${m[0]} ` +
|
|
76
|
+
'(use verdict(card-NNN), retune(system) or manual(text))',
|
|
77
|
+
);
|
|
45
78
|
}
|
|
46
79
|
});
|
|
80
|
+
|
|
47
81
|
return errors;
|
|
48
82
|
}
|
package/lib/verdict-fs.js
CHANGED
|
@@ -20,6 +20,7 @@ const MERGE_MAP = [
|
|
|
20
20
|
|
|
21
21
|
function blockLines(body, key) {
|
|
22
22
|
const m = String(body).match(new RegExp(`^${key}:\\s*\\n((?:- .*\\n?)*)`, 'm'));
|
|
23
|
+
|
|
23
24
|
return m ? m[1].split('\n').filter((l) => l.startsWith('- ')) : [];
|
|
24
25
|
}
|
|
25
26
|
|
|
@@ -29,29 +30,122 @@ function upsertBlock(body, key, addLines) {
|
|
|
29
30
|
const fresh = addLines.filter((l) => !existing.has(l));
|
|
30
31
|
if (!fresh.length) return body;
|
|
31
32
|
const re = new RegExp(`(^${key}:\\s*\\n(?:- .*\\n?)*)`, 'm');
|
|
32
|
-
if (re.test(body))
|
|
33
|
+
if (re.test(body))
|
|
34
|
+
return body.replace(
|
|
35
|
+
re,
|
|
36
|
+
(blk) => `${blk.endsWith('\n') ? blk : `${blk}\n`}${fresh.join('\n')}\n`,
|
|
37
|
+
);
|
|
38
|
+
|
|
33
39
|
return `${body.trimEnd()}\n\n${key}:\n${fresh.join('\n')}\n`;
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
// `git commit --only <paths>` is a partial commit, which git refuses outright while a merge
|
|
37
43
|
// or a sequencer operation is in progress. Detect it up front so the failure has a reason.
|
|
38
|
-
const IN_PROGRESS = [
|
|
44
|
+
const IN_PROGRESS = [
|
|
45
|
+
'MERGE_HEAD',
|
|
46
|
+
'CHERRY_PICK_HEAD',
|
|
47
|
+
'REVERT_HEAD',
|
|
48
|
+
'rebase-merge',
|
|
49
|
+
'rebase-apply',
|
|
50
|
+
];
|
|
39
51
|
function operationInProgress(projectDir) {
|
|
40
|
-
const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], {
|
|
52
|
+
const gitDir = spawnSync('git', ['rev-parse', '--git-dir'], {
|
|
53
|
+
cwd: projectDir,
|
|
54
|
+
encoding: 'utf8',
|
|
55
|
+
});
|
|
41
56
|
if (gitDir.status !== 0) return null;
|
|
42
57
|
const dir = path.resolve(projectDir, gitDir.stdout.trim());
|
|
58
|
+
|
|
43
59
|
return IN_PROGRESS.find((name) => existsSync(path.join(dir, name))) ?? null;
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
function gitCommit(projectDir, message, addPaths) {
|
|
47
63
|
// Stage and commit exactly the files this verdict touched: `git add -- gcf/` would stage a
|
|
48
64
|
// user's unrelated edits under gcf/, and rollback only restores what it snapshotted.
|
|
49
|
-
for (const p of addPaths)
|
|
50
|
-
|
|
65
|
+
for (const p of addPaths)
|
|
66
|
+
spawnSync('git', ['add', '--', p], { cwd: projectDir, stdio: 'ignore' });
|
|
67
|
+
const res = spawnSync('git', ['commit', '-m', message, '--only', '--', ...addPaths], {
|
|
68
|
+
cwd: projectDir,
|
|
69
|
+
encoding: 'utf8',
|
|
70
|
+
});
|
|
51
71
|
if (res.status !== 0) throw new Error(`verdict commit failed: ${res.stderr || res.stdout}`);
|
|
52
|
-
|
|
72
|
+
|
|
73
|
+
return spawnSync('git', ['rev-parse', 'HEAD'], {
|
|
74
|
+
cwd: projectDir,
|
|
75
|
+
encoding: 'utf8',
|
|
76
|
+
}).stdout.trim();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Find the card and refuse an unknown verdict or a transition the lifecycle forbids. */
|
|
80
|
+
async function findVerdictCard(root, cardId, verdict) {
|
|
81
|
+
const card = (await listCards(root)).find((c) => c.data.id === cardId);
|
|
82
|
+
if (!card) throw new Error(`Card not found: ${cardId}`);
|
|
83
|
+
const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
|
|
84
|
+
if (!target) throw new Error(`Unknown verdict: ${verdict}`);
|
|
85
|
+
if (!isLegal(card.data.status, target)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`Illegal transition ${card.data.status} -> ${target} for ${cardId} ` +
|
|
88
|
+
'(verdict needs status=built)',
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return card;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function planTune({ card, note }) {
|
|
96
|
+
return async () => {
|
|
97
|
+
const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
|
|
98
|
+
const body = note ? upsertBlock(card.body, 'feel', [`- retune: ${note}`]) : card.body;
|
|
99
|
+
await atomicWrite(card.file, serialize(data, body));
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function planKill({ root, card, cardId, note, tags, systems, touched }) {
|
|
104
|
+
const gyFile = path.join(root, 'graveyard.md');
|
|
105
|
+
touched.push(gyFile);
|
|
106
|
+
|
|
107
|
+
return async () => {
|
|
108
|
+
const reason = note || 'no reason recorded';
|
|
109
|
+
const tagStr = (tags.length ? tags : systems).join(', ');
|
|
110
|
+
const line = `- ${cardId} | ${card.data.slug} | ${reason} | tags: ${tagStr}\n`;
|
|
111
|
+
const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
|
|
112
|
+
await atomicWrite(gyFile, existing + line);
|
|
113
|
+
await unlink(card.file);
|
|
114
|
+
};
|
|
53
115
|
}
|
|
54
116
|
|
|
117
|
+
async function mergeIntoSystem(sysFile, system, card, cardId) {
|
|
118
|
+
let doc = existsSync(sysFile)
|
|
119
|
+
? parse(await readFile(sysFile, 'utf8'))
|
|
120
|
+
: { data: { system }, body: 'purpose:\n- (fill in)\n' };
|
|
121
|
+
let body = doc.body;
|
|
122
|
+
for (const [slot, section] of MERGE_MAP) {
|
|
123
|
+
body = upsertBlock(body, section, blockLines(card.body, slot));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Lines with a live until: move to `open` (GCF-PRD §8.2.5).
|
|
127
|
+
const openLines = blockLines(card.body, 'interactions').filter((l) => l.includes('[until:'));
|
|
128
|
+
body = upsertBlock(body, 'open', openLines);
|
|
129
|
+
await atomicWrite(sysFile, serialize({ ...doc.data, updated_by: cardId }, body));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function planKeep({ root, card, cardId, systems, touched }) {
|
|
133
|
+
// `system:` names files, and the pre-commit gate validates it only after they are written:
|
|
134
|
+
// `../../x` would already have overwritten a file outside gcf/.
|
|
135
|
+
const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
|
|
136
|
+
touched.push(...sysFiles);
|
|
137
|
+
|
|
138
|
+
return async () => {
|
|
139
|
+
for (const [i, sysFile] of sysFiles.entries()) {
|
|
140
|
+
await mergeIntoSystem(sysFile, systems[i], card, cardId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
await unlink(card.file);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const PLANS = { tune: planTune, kill: planKill, keep: planKeep };
|
|
148
|
+
|
|
55
149
|
/**
|
|
56
150
|
* The one operation that collapses a card (GCF-PRD §8). Atomic: file changes roll back
|
|
57
151
|
* on failure; the verdict commit has the fixed greppable format. `keep` merges into every
|
|
@@ -61,70 +155,39 @@ function gitCommit(projectDir, message, addPaths) {
|
|
|
61
155
|
* The commit runs INSIDE the rollback: it fires the pre-commit gate, which can reject the
|
|
62
156
|
* merged systems doc, and a card unlinked by a commit that never landed is a lost card.
|
|
63
157
|
*/
|
|
64
|
-
export async function applyVerdict(
|
|
158
|
+
export async function applyVerdict(
|
|
159
|
+
projectDir,
|
|
160
|
+
cardId,
|
|
161
|
+
verdict,
|
|
162
|
+
{ note = '', tags = [], commit = true } = {},
|
|
163
|
+
) {
|
|
65
164
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
66
|
-
const card =
|
|
67
|
-
|
|
68
|
-
const target = { keep: 'validated', tune: 'tuning', kill: 'killed' }[verdict];
|
|
69
|
-
if (!target) throw new Error(`Unknown verdict: ${verdict}`);
|
|
70
|
-
if (!isLegal(card.data.status, target)) {
|
|
71
|
-
throw new Error(`Illegal transition ${card.data.status} -> ${target} for ${cardId} (verdict needs status=built)`);
|
|
72
|
-
}
|
|
165
|
+
const card = await findVerdictCard(root, cardId, verdict);
|
|
166
|
+
|
|
73
167
|
// Before any write: refusing after the files changed would send a failed commit through the
|
|
74
168
|
// rollback, and restoring a conflicted index entry marks the conflict resolved.
|
|
75
169
|
const busy = commit ? operationInProgress(projectDir) : null;
|
|
76
|
-
if (busy)
|
|
170
|
+
if (busy)
|
|
171
|
+
throw new Error(
|
|
172
|
+
`cannot record a verdict while ${busy} is in progress — finish or abort it first`,
|
|
173
|
+
);
|
|
77
174
|
|
|
78
175
|
const touched = [card.file, path.join(root, 'INDEX.md')];
|
|
79
176
|
const systems = card.data.system ?? [];
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if (verdict === 'tune') {
|
|
83
|
-
mutate = async () => {
|
|
84
|
-
const data = { ...card.data, status: 'tuning', tune_count: (card.data.tune_count ?? 0) + 1 };
|
|
85
|
-
const body = note ? upsertBlock(card.body, 'feel', [`- retune: ${note}`]) : card.body;
|
|
86
|
-
await atomicWrite(card.file, serialize(data, body));
|
|
87
|
-
};
|
|
88
|
-
} else if (verdict === 'kill') {
|
|
89
|
-
const gyFile = path.join(root, 'graveyard.md');
|
|
90
|
-
touched.push(gyFile);
|
|
91
|
-
mutate = async () => {
|
|
92
|
-
const reason = note || 'no reason recorded';
|
|
93
|
-
const tagStr = (tags.length ? tags : systems).join(', ');
|
|
94
|
-
const line = `- ${cardId} | ${card.data.slug} | ${reason} | tags: ${tagStr}\n`;
|
|
95
|
-
const existing = existsSync(gyFile) ? await readFile(gyFile, 'utf8') : '';
|
|
96
|
-
await atomicWrite(gyFile, existing + line);
|
|
97
|
-
await unlink(card.file);
|
|
98
|
-
};
|
|
99
|
-
} else {
|
|
100
|
-
// `system:` names files, and the pre-commit gate validates it only after they are written:
|
|
101
|
-
// `../../x` would already have overwritten a file outside gcf/.
|
|
102
|
-
const sysFiles = systems.map((s) => resolveInside(path.join(root, 'systems'), `${s}.md`));
|
|
103
|
-
touched.push(...sysFiles);
|
|
104
|
-
mutate = async () => {
|
|
105
|
-
for (const [i, sysFile] of sysFiles.entries()) {
|
|
106
|
-
let doc = existsSync(sysFile)
|
|
107
|
-
? parse(await readFile(sysFile, 'utf8'))
|
|
108
|
-
: { data: { system: systems[i] }, body: 'purpose:\n- (fill in)\n' };
|
|
109
|
-
let body = doc.body;
|
|
110
|
-
for (const [slot, section] of MERGE_MAP) {
|
|
111
|
-
body = upsertBlock(body, section, blockLines(card.body, slot));
|
|
112
|
-
}
|
|
113
|
-
// Lines with a live until: move to `open` (GCF-PRD §8.2.5).
|
|
114
|
-
const openLines = blockLines(card.body, 'interactions').filter((l) => l.includes('[until:'));
|
|
115
|
-
body = upsertBlock(body, 'open', openLines);
|
|
116
|
-
await atomicWrite(sysFile, serialize({ ...doc.data, updated_by: cardId }, body));
|
|
117
|
-
}
|
|
118
|
-
await unlink(card.file);
|
|
119
|
-
};
|
|
120
|
-
}
|
|
177
|
+
const mutate = PLANS[verdict]({ root, card, cardId, note, tags, systems, touched });
|
|
121
178
|
|
|
122
179
|
const message = `gcf(verdict): ${verdict} ${cardId} ${card.data.slug} → ${systems.join(',')}`;
|
|
180
|
+
/** @type {string | null} */
|
|
123
181
|
let hash = null;
|
|
124
|
-
await withFileRollback(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
182
|
+
await withFileRollback(
|
|
183
|
+
touched,
|
|
184
|
+
async () => {
|
|
185
|
+
await mutate();
|
|
186
|
+
await writeIndex(root);
|
|
187
|
+
if (commit) hash = gitCommit(projectDir, message, touched);
|
|
188
|
+
},
|
|
189
|
+
{ gitCwd: commit ? path.resolve(projectDir) : null },
|
|
190
|
+
);
|
|
191
|
+
|
|
129
192
|
return { verdict, card: cardId, systems, hash };
|
|
130
193
|
}
|
package/package.json
CHANGED
|
@@ -24,7 +24,7 @@ forbidden_behaviors:
|
|
|
24
24
|
- do not read code files whole: names, paths and entry points are the whole input
|
|
25
25
|
|
|
26
26
|
workflow:
|
|
27
|
-
1. run `ai-factory gcf init` and report what it created; when
|
|
27
|
+
1. run `ai-factory gcf init` and report what it created; when `.ai-factory/DESCRIPTION.md` is absent, tell the human to run `/aif` before the first `/gcf-card`
|
|
28
28
|
2. scan the code tree by names only - directories, `*.asmdef`, feature classes, entry points - and propose a subsystem list with a path per entry
|
|
29
29
|
3. ask the human to edit and confirm the names; a name they reject or doubt becomes a D1 record in `gcf/open-questions.md`, not a file
|
|
30
30
|
4. write `gcf/systems/<name>.md` per confirmed subsystem from the package template, frontmatter `system: <name>` and `updated_by: adopt`
|
|
@@ -32,7 +32,7 @@ workflow:
|
|
|
32
32
|
6. fill `purpose:` with one line of intent the human confirmed: the INDEX row of a subsystem is its first `purpose` bullet
|
|
33
33
|
7. fill `behavior:` and `constraints:` with 1 placeholder each, annotated `[until: manual(first verdict touching <name>)]`: the profile requires both blocks, and a `manual(...)` trigger is not an audit finding
|
|
34
34
|
8. write a constraint the human states out loud into `constraints:`, and do not interview subsystem by subsystem for more: unanswered questions on adoption day are the staleness this framework exists to remove
|
|
35
|
-
9. draft `PILLARS.md` by the interview in the `gcf-init` skill, steps
|
|
35
|
+
9. draft `PILLARS.md` by the interview in the `gcf-init` skill, steps 4..6, and do not restate that interview here
|
|
36
36
|
10. run `npx cnlp check`, then `ai-factory gcf lint` over each new systems doc, then `ai-factory gcf index`
|
|
37
37
|
11. ask the human to run `ai-factory gcf hooks-install`; when it refuses over a foreign pre-commit, hand them the chain line it prints
|
|
38
38
|
12. state the next step: an old design document goes through `/gcf-ingest`, new work starts at `/gcf-card`
|