gcf-aif-ext 0.3.0 → 0.4.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.
Files changed (48) hide show
  1. package/README.md +53 -11
  2. package/commands/gcf.js +287 -42
  3. package/extension.json +22 -5
  4. package/hooks/pre-commit.js +55 -31
  5. package/injections/commit-status.md +7 -5
  6. package/injections/plan-context.md +8 -5
  7. package/injections/review-pillars.md +4 -3
  8. package/lib/anchors.js +27 -4
  9. package/lib/audit-core.js +135 -48
  10. package/lib/bundle.js +7 -4
  11. package/lib/context.js +24 -23
  12. package/lib/design-check.js +78 -27
  13. package/lib/design.js +2 -2
  14. package/lib/gcf-paths.js +13 -2
  15. package/lib/id.js +2 -2
  16. package/lib/inbox.js +54 -0
  17. package/lib/index-gen.js +23 -8
  18. package/lib/ledger.js +19 -17
  19. package/lib/linter.js +2 -1
  20. package/lib/live.js +179 -0
  21. package/lib/plan-check.js +31 -0
  22. package/lib/playtest-list.js +8 -4
  23. package/lib/systems-doc.js +16 -1
  24. package/lib/transitions.js +36 -21
  25. package/lib/unit-ops.js +124 -97
  26. package/lib/validate-item.js +244 -0
  27. package/lib/verdict-fs.js +61 -128
  28. package/lib/work-ops.js +727 -0
  29. package/package.json +1 -1
  30. package/profiles/pillars.md +1 -1
  31. package/profiles/system.md +1 -1
  32. package/profiles/unit-constraint.md +10 -0
  33. package/profiles/unit-entity.md +10 -0
  34. package/profiles/unit-mechanic.md +6 -1
  35. package/profiles/unit-resource.md +6 -1
  36. package/profiles/unit-rule.md +10 -0
  37. package/schemas/item.schema.json +105 -0
  38. package/skills/gcf-adopt/SKILL.md +2 -2
  39. package/skills/gcf-audit/SKILL.md +3 -3
  40. package/skills/gcf-card/SKILL.md +25 -20
  41. package/skills/gcf-ingest/SKILL.md +3 -3
  42. package/skills/gcf-init/SKILL.md +2 -2
  43. package/skills/gcf-triage/SKILL.md +35 -0
  44. package/skills/gcf-verdict/SKILL.md +19 -16
  45. package/templates/config.yml +7 -0
  46. package/templates/{card.md → item.md} +10 -3
  47. package/lib/validate-card.js +0 -93
  48. package/schemas/card.schema.json +0 -46
@@ -6,13 +6,15 @@
6
6
  import { existsSync } from 'node:fs';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import path from 'node:path';
9
- import { listSystems } from '../lib/gcf-paths.js';
10
- import { validateCard, validateUntil } from '../lib/validate-card.js';
9
+ import { listSystems, legacyCards } from '../lib/gcf-paths.js';
10
+ import { validateItem, validateUntil } from '../lib/validate-item.js';
11
11
  import { stemMatches } from '../lib/id.js';
12
12
  import { writeIndex } from '../lib/index-gen.js';
13
13
  import { lint } from '../lib/linter.js';
14
14
  import { buildLedger } from '../lib/ledger.js';
15
15
  import { designCheck } from '../lib/design-check.js';
16
+ import { bodyHash } from '../lib/work-ops.js';
17
+ import { checkLive } from '../lib/live.js';
16
18
 
17
19
  const projectDir = process.cwd();
18
20
  const root = path.join(projectDir, 'gcf');
@@ -26,36 +28,58 @@ const fail = (msg) => {
26
28
  // null when git itself failed (unborn HEAD, no work tree) — then fall through to the gate
27
29
  // rather than letting a broken probe wave a commit past every check.
28
30
  function git(args) {
29
- const res = spawnSync('git', args, { cwd: projectDir, encoding: 'utf8' });
31
+ // quotePath off: a non-ASCII path in the refusal reads as itself, not as octal escapes.
32
+ const res = spawnSync('git', ['-c', 'core.quotePath=false', ...args], {
33
+ cwd: projectDir,
34
+ encoding: 'utf8',
35
+ });
30
36
 
31
37
  return res.status === 0 ? res.stdout.trim() : null;
32
38
  }
33
39
 
34
- const staged = git(['diff', '--cached', '--name-only', '--', root]);
40
+ // gcf/.metrics is left out: `gcf audit` and `gcf context` append to it on every run, and the
41
+ // gate reads none of it. Hooks run from the repository root, so the pathspecs are relative.
42
+ const scope = ['--', 'gcf', ':(exclude)gcf/.metrics'];
43
+ const staged = git(['diff', '--cached', '--name-only', ...scope]);
35
44
  if (staged === '') process.exit(0); // this commit does not touch gcf/
36
45
  if (staged !== null) {
37
- // Untracked counts: cards and units are found by reading directories, so an uncommitted file
46
+ // Untracked counts: items and units are found by reading directories, so an uncommitted file
38
47
  // would be validated and written into the staged INDEX while not being in the repository.
39
- const unstaged = git(['diff', '--name-only', '--', root]);
40
- const untracked = git(['ls-files', '--others', '--exclude-standard', '--', root]);
41
- if (unstaged || untracked) {
48
+ const outside = [
49
+ git(['diff', '--name-only', ...scope]),
50
+ git(['ls-files', '--others', '--exclude-standard', ...scope]),
51
+ ]
52
+ .filter(Boolean)
53
+ .join('\n');
54
+ if (outside) {
55
+ // A move (verdict, take, close, drop…) commits only its own files, so staging does not help it.
42
56
  fail(
43
- 'gcf/ has changes outside the index — stage them, or commit gcf/ separately ' +
57
+ `gcf/ has changes outside the index (${outside.split('\n').join(', ')}) — stage them, ` +
58
+ 'or commit gcf/ separately; before a gcf move, commit or stash them ' +
44
59
  '(the gate reads the working tree)',
45
60
  );
46
61
  }
47
62
  }
48
63
 
64
+ // Schema 2 cards are not read by 0.4: left in place they would silently stop being checked.
65
+ const legacy = legacyCards(root);
66
+ if (legacy.length)
67
+ fail(
68
+ `gcf/cards/ holds schema 2 card(s) (${legacy.join(', ')}): GCF 0.4 keeps work in ` +
69
+ 'gcf/work/<id>.md with schema 3 and does not migrate — put each card back into ' +
70
+ 'gcf/design/ as a unit and run ai-factory gcf take, then delete gcf/cards/',
71
+ );
72
+
49
73
  const ledger = await buildLedger(projectDir);
50
74
 
51
75
  // 1-2: frontmatter validity, storable statuses, until grammar, filename invariant.
52
- for (const c of ledger.cards) {
76
+ for (const c of ledger.items) {
53
77
  const errors = [
54
- ...validateCard(c.data),
78
+ ...validateItem(c.data),
55
79
  ...validateUntil(c.body),
56
80
  ...(stemMatches(c.file, c.data.id) ? [] : [`filename must be ${c.data.id}.md`]),
57
81
  ];
58
- if (errors.length) fail(`${c.name}: ${errors[0]}`);
82
+ if (errors.length) fail(`work/${c.name}: ${errors[0]}`);
59
83
  }
60
84
 
61
85
  for (const s of await listSystems(root)) {
@@ -63,35 +87,35 @@ for (const s of await listSystems(root)) {
63
87
  if (errors.length) fail(`systems/${s.name}: ${errors[0]}`);
64
88
  }
65
89
 
66
- // A unit or card leaving without its move (take, done, drop, a verdict) loses its reason, not
67
- // its integrity: a card or unit still citing it fails M2 below, in this commit. A warning, not a
68
- // refusal — a revert of the commit that added it removes an id legitimately.
90
+ // An unplayed item's body is the source of its systems/ lines (Methodology §5.4): edited after
91
+ // its close, a tune or a kill would take the wrong lines out. `close` froze it by hash.
92
+ for (const c of ledger.items.filter((x) => x.data.status === 'unplayed')) {
93
+ if (bodyHash(c.body) !== c.data.frozen)
94
+ fail(`work/${c.name}: an unplayed item's body is frozen — tune it to change it`);
95
+ }
96
+
97
+ // A unit or item leaving without its move loses its reason, not its integrity: one still citing
98
+ // it fails M2 below, in this commit. A warning, not a refusal — a revert of the commit that
99
+ // added it removes an id legitimately.
69
100
  for (const id of ledger.vanished()) {
70
101
  console.error(
71
- `gcf pre-commit: warning: ${id} left design/ or cards/ without done, drop or a verdict — ` +
102
+ `gcf pre-commit: warning: ${id} left design/ or work/ without a move — ` +
72
103
  'no history or graveyard line keeps why',
73
104
  );
74
105
  }
75
106
 
76
- // Design model and card bodies: every finding of severity `block` refuses the commit.
107
+ // Design model and item bodies: every finding of severity `block` refuses the commit.
77
108
  const first = (await designCheck(projectDir)).issues.find((i) => i.severity === 'block');
78
109
  if (first) fail(`${first.file}:${first.line}: ${first.check}: ${first.message}`);
79
110
 
80
- // 4: a built card must not sit on an unplayed dependency — a live card, or a unit still in
81
- // design/. A collapsed, done or killed id is played; an unknown one is a typo (design check).
82
- for (const c of ledger.cards.filter((x) => x.data.status === 'built')) {
83
- for (const dep of c.data.depends ?? []) {
84
- const live = ledger.where(dep).live[0];
85
- if (live)
86
- fail(`${c.data.id} is built on top of unplayed ${dep} (${live.state} — GCF-PRD §7.2)`);
87
- }
88
- }
111
+ // Dependencies are held by the moves, not here (Methodology §4.1): `submit` refuses an unplayed
112
+ // one, `close` and `keep` a not-yet-kept one. A gate check would refuse the tune or the kill of
113
+ // a dependency whose dependent is already waiting for its verdict — a legal move, blocked.
89
114
 
90
- for (const c of ledger.cards) {
91
- const unknown = (c.data.depends ?? []).find((d) => !ledger.known(d));
92
- if (unknown)
93
- fail(`${c.data.id} depends on unknown ${unknown} (no unit, card, history or graveyard line)`);
94
- }
115
+ // LIVE.md form: a malformed record would hide from every reader of the snapshot.
116
+ const live = await checkLive(projectDir);
117
+ const form = live.problems[0] ?? live.issues.find((i) => /summary|twice/.test(i));
118
+ if (form) fail(`LIVE.md: ${form}`);
95
119
 
96
120
  // 5: П2 linter over systems docs.
97
121
  for (const s of await listSystems(root)) {
@@ -1,7 +1,9 @@
1
- ## GCF: card status before commit (from gcf-aif-ext)
1
+ ## GCF: work item status after commit (from gcf-aif-ext)
2
2
 
3
3
  gcf_commit:
4
- - when `gcf/` exists and the commit implements a card, verify the card frontmatter is current
5
- - an implementation commit that completes a build flips `status:` to `built` and records `built_at:` with the commit hash in the same commit
6
- - do not commit a card file whose `status:` is `validated` or `killed`: those are terminal and never stored
7
- - when the commit makes a hard unit's tests green, run `ai-factory gcf done <id>` after it: the unit leaves `gcf/design/` in its own fixed-format commit
4
+ - 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
5
+ - gate `verdict` or `decision`: run `ai-factory gcf submit <item-id>` — it waits for the operator, `since:` records where the wait began
6
+ - gate `auto`, every check green: write the item's `live:` delta, then run `ai-factory gcf close <item-id>` — never edit `status:` by hand
7
+ - the work cannot go on: run `ai-factory gcf block <item-id> --reason "<why or the question>"`
8
+ - do not write a terminal status into an item file: a terminal move deletes the file and writes the history or graveyard line itself
9
+ - when the commit makes the tests of a hard unit still in `gcf/design/` green, run `ai-factory gcf done <id>` after it
@@ -1,9 +1,12 @@
1
1
  ## GCF: readset before planning (from gcf-aif-ext)
2
2
 
3
3
  gcf_gate:
4
- - when `gcf/` exists and the task names a card, run `ai-factory gcf context <card-id>` first
5
- - the returned file list is the readset: read those files and nothing else under `gcf/`
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
6
6
  - `over_budget: true` in the result: stop and record a D1 question instead of planning
7
- - an id in `depends:` that still lives as a card or as a unit of `gcf/design/` is unplayed: do not plan on top of it, stop
8
- - shape the plan by the card slots: intent, behavior, refusal, boundaries, persistence, feel envelopes, contract, interactions, constraints, miss criterion
9
- - when the task names a hard unit (rule, constraint, entity), there is no card: read its section only, from the file `ai-factory gcf where <id>` names, and plan its tests from `statement`, `threshold` or `fields`
7
+ - 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
+ - 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>"`)
9
+ - write the plan to the item's `plan:` path, by default `.ai-factory/plans/<item-id>.md`, with task checkboxes
10
+ - a design-unit item: shape the plan by its slots — intent, behavior, refusal, boundaries, persistence, feel envelopes, contract, interactions, constraints, miss criterion; a hard unit plans its tests from `statement`, `threshold` or `fields`
11
+ - a bug item: the first task writes the test that reproduces `repro:`, the fix comes after it
12
+ - after the plan, run `ai-factory gcf plan check <item-id>`: a failure is fixed in the plan before implementation
@@ -1,7 +1,8 @@
1
1
  ## GCF: pillar and constraint review (from gcf-aif-ext)
2
2
 
3
3
  gcf_review:
4
- - when `gcf/` exists and the change implements a card, read the card and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
5
- - check the diff against every negative constraint of the card: name each violation with file and line
4
+ - when `gcf/` exists and the change implements a work item, read the item and `gcf/PILLARS.md` when it exists (it stays absent until the pillars interview)
5
+ - check the diff against every negative constraint of the item: name each violation with file and line
6
6
  - check the diff against each pillar's review question: a contradiction is a finding, not a note
7
- - numeric feel values in code outside the card's envelope: report the value and the envelope bounds
7
+ - numeric feel values in code outside the item's envelope: report the value and the envelope bounds
8
+ - a capability the diff adds, changes or removes that the item's `live:` delta does not name: a finding
package/lib/anchors.js CHANGED
@@ -26,6 +26,28 @@ const EMPTY_HASH = h8('');
26
26
  // human with D1 questions about a GDD nobody edited.
27
27
  export const HASH_VERSION = 2;
28
28
 
29
+ /**
30
+ * Line indexes inside a closed fenced block: a `# comment` there is code, not a heading. A fence
31
+ * that never closes is ignored — a stray ``` must not swallow every heading after it.
32
+ */
33
+ function fencedLines(lines) {
34
+ const inside = new Set();
35
+ let open = -1;
36
+ let fence = '';
37
+ for (const [i, line] of lines.entries()) {
38
+ const f = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
39
+ if (!f) continue;
40
+ if (open < 0) [open, fence] = [i, f[1]];
41
+ // CommonMark: the closing fence is the same character, at least as long, with no info string.
42
+ else if (f[1][0] === fence[0] && f[1].length >= fence.length && !f[2].trim()) {
43
+ for (let j = open + 1; j < i; j++) inside.add(j);
44
+ open = -1;
45
+ }
46
+ }
47
+
48
+ return inside;
49
+ }
50
+
29
51
  /**
30
52
  * Split markdown into heading-addressed sections.
31
53
  * Text before the first heading is `GDD#preamble`.
@@ -44,8 +66,9 @@ export function splitSections(markdown) {
44
66
  sections.push(current);
45
67
  };
46
68
 
69
+ const fenced = fencedLines(lines);
47
70
  for (const [i, line] of lines.entries()) {
48
- const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
71
+ const m = !fenced.has(i) && line.match(/^(#{1,6})\s+(.+?)\s*$/);
49
72
  if (m) {
50
73
  flush();
51
74
  current = { heading: m[2], level: m[1].length, start: i + 1, lines: [] };
@@ -171,10 +194,10 @@ export async function sourceBodies(sourceDir) {
171
194
  return bodies;
172
195
  }
173
196
 
174
- /** gcf-relative files that cite anchors: units, cards, and the `sections:` of questions. */
197
+ /** gcf-relative files that cite anchors: units, work items, and the `sections:` of questions. */
175
198
  async function anchorCiters(root) {
176
199
  const out = [];
177
- for (const dir of ['design', 'cards']) {
200
+ for (const dir of ['design', 'work']) {
178
201
  if (!existsSync(path.join(root, dir))) continue;
179
202
  const names = (await readdir(path.join(root, dir))).filter((n) => n.endsWith('.md'));
180
203
  out.push(...names.map((n) => `${dir}/${n}`));
@@ -187,7 +210,7 @@ async function anchorCiters(root) {
187
210
 
188
211
  /**
189
212
  * A renamed heading changes its anchor (GCF-PRD §11.6): rewrite `GDD#<from>` to `GDD#<to>` in
190
- * design/, cards/ and open-questions.md, so provenance, `source:` and `sections:` follow the
213
+ * design/, work/ and open-questions.md, so provenance, `source:` and `sections:` follow the
191
214
  * section instead of pointing at an anchor that no longer exists. Returns the files it changed.
192
215
  */
193
216
  export async function rewriteAnchors(projectDir, renamed) {
package/lib/audit-core.js CHANGED
@@ -2,74 +2,96 @@ import { readFile } from 'node:fs/promises';
2
2
  import { existsSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { spawnSync } from 'node:child_process';
5
- import { listCards, listSystems, readConfig, tokensEst } from './gcf-paths.js';
5
+ import { listItems, listSystems, readConfig, tokensEst, legacyCards } from './gcf-paths.js';
6
6
  import { lint } from './linter.js';
7
7
  import { drifted } from './index-gen.js';
8
- import { designCheck } from './design-check.js';
8
+ import { designCheck, recordsIn } from './design-check.js';
9
9
  import { buildLedger } from './ledger.js';
10
+ import { STORED } from './transitions.js';
11
+ import { openDepends } from './work-ops.js';
10
12
 
11
13
  function git(args, cwd) {
12
- const res = spawnSync('git', args, { cwd, encoding: 'utf8' });
14
+ // A blame of a long file overflows the 1 MiB default, and an overflow reads as a git failure.
15
+ const res = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
13
16
 
14
17
  return res.status === 0 ? res.stdout.trim() : null;
15
18
  }
16
19
 
17
20
  const UNTIL_RE = /\[until:\s*(verdict\([a-z0-9-]+\)|retune\([a-z0-9-]+\)|manual\([^)]*\))\]/g;
18
21
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
- const LEGAL_STATUS = ['proposed', 'built', 'tuning'];
20
22
 
21
23
  /**
22
24
  * @typedef {object} AuditReport
23
25
  * @property {string} first_line
24
- * @property {Array<{id: string, commits: number | null, limit: number}>} stale_built
26
+ * @property {Array<{id: string, status: string, commits: number | null, limit: number}>}
27
+ * stale_waiting
25
28
  * @property {Array<{id: string, tune_count: number, limit: number}>} tune_limit
26
29
  * @property {Array<{id: string, depends: string[]}>} open_depends
27
30
  * @property {{count: number, limit: number, over: boolean}} wip
28
- * @property {{tokens: number, budget: number, over: boolean} | null} pillars
31
+ * @property {{count: number, warn_after: number, over: boolean}} unplayed
32
+ * @property {string[]} legacy_cards
33
+ * @property {{tokens: number, budget: number, over: boolean, count: number, too_many: boolean}
34
+ * | null} pillars
29
35
  * @property {Array<{file: string, trigger: string, status: string}>} until
30
36
  * @property {Record<string, object[]>} linter
31
37
  * @property {object[]} pragmas
32
38
  * @property {boolean} index_drift
33
39
  * @property {Array<{id: string, status: string}>} illegal_status
40
+ * @property {Array<{id: string, commits: number | null, limit: number}>} stale_questions
34
41
  * @property {number} [schema]
35
- * @property {Array<{id: string, status: string, system: string[], depends: string[]}>} [cards]
42
+ * @property {Array<{id: string, type: string, gate: string, status: string, system: string[],
43
+ * depends: string[], parent: string | null, tune_count: number}>} [items]
36
44
  * @property {{blocking: number, units: number, ready: number}} [design]
37
45
  */
38
46
 
39
47
  /**
40
- * Deterministic audit core (GCF-PRD §10.1): until triggers, built staleness, tune
41
- * limits, WIP, PILLARS budget, П2 linter, INDEX drift. No LLM anywhere; the
48
+ * Deterministic audit core (GCF-PRD §10.1): until triggers, waiting staleness, tune limits,
49
+ * WIP, unplayed count, PILLARS budget, П2 linter, INDEX drift. No LLM anywhere; the
42
50
  * contradiction hunt is a separate optional skill pass.
43
51
  */
44
52
  export async function audit(projectDir) {
45
53
  const root = path.join(path.resolve(projectDir), 'gcf');
46
54
  const config = await readConfig(root);
47
- const cards = await listCards(root);
55
+ const items = await listItems(root);
48
56
  const systems = await listSystems(root);
57
+ const ledger = await buildLedger(projectDir);
58
+ const unplayed = items.filter((c) => c.data.status === 'unplayed').length;
49
59
  /** @type {AuditReport} */
50
60
  const report = {
51
61
  first_line: '',
52
- stale_built: [],
62
+ stale_waiting: [],
53
63
  tune_limit: [],
54
64
  open_depends: [],
55
- wip: wipOf(cards, config),
65
+ wip: wipOf(items, config),
66
+ unplayed: {
67
+ count: unplayed,
68
+ warn_after: config.unplayed_warn_after,
69
+ over: unplayed > config.unplayed_warn_after,
70
+ },
71
+ legacy_cards: legacyCards(root),
56
72
  pillars: await pillarsOf(root, config),
57
- until: untilRecords(cards, systems, projectDir, await buildLedger(projectDir)),
73
+ until: untilRecords(items, systems, projectDir, ledger),
58
74
  linter: {},
59
75
  pragmas: [],
60
76
  index_drift: await drifted(root),
61
77
  illegal_status: [],
78
+ stale_questions: staleQuestions(projectDir, root, config),
62
79
  };
63
80
  // The harness gate reads everything from this one report — it never parses gcf/ itself.
64
81
  report.schema = config.schema;
65
- report.cards = cards.map((c) => ({
82
+ report.items = items.map((c) => ({
66
83
  id: c.data.id,
84
+ type: c.data.type,
85
+ gate: c.data.gate,
67
86
  status: c.data.status,
68
87
  system: c.data.system ?? [],
69
88
  depends: c.data.depends ?? [],
89
+ parent: c.data.parent ?? null,
90
+ tune_count: c.data.tune_count ?? 0,
91
+ parallel: c.data.parallel ?? 'serial',
70
92
  }));
71
93
  const design = await designCheck(projectDir);
72
- checkCards(report, cards, config, { projectDir, designIds: design.units.map((u) => u.id) });
94
+ checkItems(report, items, config, { projectDir, ledger });
73
95
  report.design = {
74
96
  blocking: design.blocking,
75
97
  units: design.units.length,
@@ -82,24 +104,25 @@ export async function audit(projectDir) {
82
104
  }
83
105
 
84
106
  /**
85
- * Per-card checks: illegal status, built staleness, tune limit, open depends. `designIds` are
86
- * units still in gcf/design/: a card depending on one builds on what nobody has played yet.
107
+ * Per-item checks: illegal status, waiting staleness, tune limit, open depends — a dependency
108
+ * still in design/ or in work, not unplayed or parked, is what nobody has played yet.
87
109
  */
88
- function checkCards(report, cards, config, { projectDir, designIds }) {
89
- const liveIds = new Set([...cards.map((c) => c.data.id), ...designIds]);
110
+ function checkItems(report, items, config, { projectDir, ledger }) {
90
111
  // Staleness is measured in commits, so outside a repository there is no answer to give.
91
- // Without this probe every built card would read as stale and stop the harness.
112
+ // Without this probe every waiting item would read as stale and stop the harness.
92
113
  const inRepo = git(['rev-parse', '--git-dir'], projectDir) !== null;
93
114
 
94
- for (const c of cards) {
95
- if (!LEGAL_STATUS.includes(c.data.status)) {
115
+ for (const c of items) {
116
+ if (!STORED.includes(c.data.status)) {
96
117
  report.illegal_status.push({ id: c.data.id, status: c.data.status });
97
118
  }
98
119
 
99
120
  const stale = inRepo ? staleEntry(c, config, projectDir) : null;
100
- if (stale) report.stale_built.push(stale);
121
+ if (stale) report.stale_waiting.push(stale);
101
122
 
102
- if ((c.data.tune_count ?? 0) >= config.tune_limit) {
123
+ // At the limit, only the next verdict matters: a ready item still gets its last build.
124
+ const verdictDue = ['awaiting-verdict', 'unplayed'].includes(c.data.status);
125
+ if (verdictDue && (c.data.tune_count ?? 0) >= config.tune_limit) {
103
126
  report.tune_limit.push({
104
127
  id: c.data.id,
105
128
  tune_count: c.data.tune_count,
@@ -107,23 +130,26 @@ function checkCards(report, cards, config, { projectDir, designIds }) {
107
130
  });
108
131
  }
109
132
 
110
- // A depends id that still has a card file is unplayed (GCF-PRD §7.2). Data for the
111
- // harness gate, not an alert: a proposed card waiting on its dependency is normal.
112
- const open = (c.data.depends ?? []).filter((d) => liveIds.has(d));
133
+ // Data for the harness gate, not an alert: a ready item waiting on its dependency is normal.
134
+ // A run may start on a dependency awaiting its verdict (Methodology §3.2); close and keep
135
+ // check the stricter rule themselves.
136
+ const open = openDepends(ledger, c.data.depends ?? [], { waiting: true });
113
137
  if (open.length) report.open_depends.push({ id: c.data.id, depends: open });
114
138
  }
115
139
  }
116
140
 
117
- /** A stale_built entry for a built card past the commit limit, else null. */
141
+ /** A stale_waiting entry for an item waiting on the operator past the commit limit, else null. */
118
142
  function staleEntry(c, config, projectDir) {
119
- if (c.data.status !== 'built' || !c.data.built_at) return null;
143
+ const waits = ['awaiting-verdict', 'awaiting-decision', 'blocked'].includes(c.data.status);
144
+ if (!waits || !c.data.since) return null;
120
145
  // git() is null when the ref no longer resolves (rebase, squash, amend, shallow clone).
121
146
  // Number(null) is 0, which reads as "fresh" — the one answer that must never be guessed.
122
- const raw = git(['rev-list', '--count', `${c.data.built_at}..HEAD`], projectDir);
147
+ const raw = git(['rev-list', '--count', `${c.data.since}..HEAD`], projectDir);
123
148
  const n = raw === null ? NaN : Number(raw);
124
149
  if (!Number.isFinite(n) || n > config.built_stale_after_commits) {
125
150
  return {
126
151
  id: c.data.id,
152
+ status: c.data.status,
127
153
  commits: Number.isFinite(n) ? n : null,
128
154
  limit: config.built_stale_after_commits,
129
155
  };
@@ -132,33 +158,84 @@ function staleEntry(c, config, projectDir) {
132
158
  return null;
133
159
  }
134
160
 
135
- function wipOf(cards, config) {
136
- const count = cards.filter((c) => c.data.status === 'built').length;
161
+ /** WIP counts what waits for the playtest; unplayed items have their own warning (§4). */
162
+ function wipOf(items, config) {
163
+ const count = items.filter((c) => c.data.status === 'awaiting-verdict').length;
137
164
 
138
165
  return { count, limit: config.wip_built_limit, over: count > config.wip_built_limit };
139
166
  }
140
167
 
168
+ // GCF-PRD §5.3: no more than 7 pillars.
169
+ const PILLARS_MAX = 7;
170
+
141
171
  async function pillarsOf(root, config) {
142
172
  const pillarsFile = path.join(root, 'PILLARS.md');
143
173
  if (!existsSync(pillarsFile)) return null;
144
- const tokens = tokensEst(await readFile(pillarsFile, 'utf8'));
174
+ const text = await readFile(pillarsFile, 'utf8');
175
+ const tokens = tokensEst(text);
176
+ const count = recordsIn(text, 'pillars').length;
177
+
178
+ return {
179
+ tokens,
180
+ budget: config.pillars_budget,
181
+ over: tokens > config.pillars_budget,
182
+ count,
183
+ too_many: count > PILLARS_MAX,
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Open questions older than the limit, aged by the last commit that changed their `- id:` line
189
+ * beyond whitespace (GCF-PRD §7.4): a pile of unanswered ones means the human has dropped out
190
+ * of the loop.
191
+ */
192
+ function staleQuestions(projectDir, root, config) {
193
+ if (!existsSync(path.join(root, 'open-questions.md'))) return [];
194
+ // null for an untracked file or outside a repository: no commit to age from. -w: a respaced
195
+ // or re-ended (CRLF/LF) line keeps the commit that wrote it.
196
+ const blame = git(['blame', '-w', '--porcelain', '--', 'gcf/open-questions.md'], projectDir);
197
+ if (blame === null) return [];
198
+ const added = [];
199
+ let hash = '';
200
+ for (const line of blame.split('\n')) {
201
+ const head = line.match(/^([0-9a-f]{40,64}) \d+ \d+/);
202
+ if (head) hash = head[1];
203
+ const id = line.match(/^\t\uFEFF?- id:\s*(\S+)/)?.[1];
204
+ // An uncommitted line blames to the all-zero hash: it is new, not old.
205
+ if (id && !/^0+$/.test(hash)) added.push({ id, hash });
206
+ }
207
+
208
+ const ages = new Map();
209
+ const ageOf = (h) => {
210
+ if (!ages.has(h)) {
211
+ const raw = git(['rev-list', '--count', `${h}..HEAD`], projectDir);
212
+ // Number(null) is 0, "fresh" — the one answer that must never be guessed (see staleEntry).
213
+ ages.set(h, raw === null ? null : Number(raw));
214
+ }
215
+
216
+ return ages.get(h);
217
+ };
218
+
219
+ const limit = config.question_stale_after_commits;
145
220
 
146
- return { tokens, budget: config.pillars_budget, over: tokens > config.pillars_budget };
221
+ return added
222
+ .map(({ id, hash: h }) => ({ id, commits: ageOf(h), limit }))
223
+ .filter((q) => q.commits === null || q.commits > limit);
147
224
  }
148
225
 
149
226
  /**
150
227
  * Whether a verdict fired. `verdict(id)` reads the files, as the ledger does (GCF-PRD §5.8): a
151
- * keep or kill left a history or graveyard line, a tune left the card's tune_count — so a
228
+ * keep or kill left a history or graveyard line, a tune left the item's tune_count — so a
152
229
  * squash or a shallow clone does not hide it, and a revert takes it back.
153
230
  */
154
- function verdictMatchers(projectDir, ledger, cards) {
155
- const tuned = cards.filter((c) => (c.data.tune_count ?? 0) > 0);
231
+ function verdictMatchers(projectDir, ledger, items) {
232
+ const tuned = items.filter((c) => (c.data.tune_count ?? 0) > 0);
156
233
  const verdictLog = git(['log', '--grep', '^gcf(verdict):', '--format=%s'], projectDir) ?? '';
157
234
 
158
235
  return {
159
236
  verdict: (id) =>
160
237
  ledger.where(id).terminal.some((t) => t !== 'done') || tuned.some((c) => c.data.id === id),
161
- // shortcut: a kept card's tunes leave no file behind, so retune(sys) also greps the verdict
238
+ // shortcut: a kept item's tunes leave no file behind, so retune(sys) also greps the verdict
162
239
  // subjects; upgrade when tunes get their own history line.
163
240
  retune: (system) =>
164
241
  tuned.some((c) => (c.data.system ?? []).includes(system)) ||
@@ -180,11 +257,11 @@ function untilStatus(trigger, fired) {
180
257
  return 'manual';
181
258
  }
182
259
 
183
- /** Until annotations across cards and systems docs. */
184
- function untilRecords(cards, systems, projectDir, ledger) {
185
- const fired = verdictMatchers(projectDir, ledger, cards);
260
+ /** Until annotations across work items and systems docs. */
261
+ function untilRecords(items, systems, projectDir, ledger) {
262
+ const fired = verdictMatchers(projectDir, ledger, items);
186
263
  const docs = [
187
- ...cards.map((c) => ({ rel: `cards/${c.name}`, body: c.body })),
264
+ ...items.map((c) => ({ rel: `work/${c.name}`, body: c.body })),
188
265
  ...systems.map((s) => ({ rel: `systems/${s.name}`, body: s.body })),
189
266
  ];
190
267
  const until = [];
@@ -206,19 +283,29 @@ function lintSystems(report, systems) {
206
283
  }
207
284
  }
208
285
 
209
- /** Alerts about cards in flight, which lead the line. @param {AuditReport} report */
210
- const cardAlerts = (report) => [
211
- report.illegal_status.length && `${report.illegal_status.length} card(s) in illegal status`,
212
- report.stale_built.length && `${report.stale_built.length} built card(s) stale — playtest first`,
286
+ /** Alerts about items in flight, which lead the line. @param {AuditReport} report */
287
+ const itemAlerts = (report) => [
288
+ report.legacy_cards.length &&
289
+ `${report.legacy_cards.length} schema 2 card(s) in gcf/cards/ — back to gcf/design/, then take`,
290
+ report.illegal_status.length && `${report.illegal_status.length} item(s) in illegal status`,
291
+ report.stale_waiting.length &&
292
+ `${report.stale_waiting.length} item(s) waiting on you too long — playtest or answer first`,
213
293
  report.tune_limit.length &&
214
- `${report.tune_limit.length} card(s) at tune limit — kill or redesign`,
294
+ `${report.tune_limit.length} item(s) at tune limit — kill or redesign`,
215
295
  report.wip.over && `WIP over limit (${report.wip.count}/${report.wip.limit})`,
296
+ report.unplayed.over &&
297
+ `${report.unplayed.count} unplayed item(s) (warn after ` +
298
+ `${report.unplayed.warn_after}) — play them`,
216
299
  ];
217
300
 
218
301
  /** Alerts about the documents. @param {AuditReport} report */
219
302
  const docAlerts = (report) => [
220
303
  report.until.filter((u) => u.status === 'stale').length && 'stale until-records present',
221
304
  report.pillars?.over && 'PILLARS over token budget',
305
+ report.pillars?.too_many && `PILLARS has ${report.pillars.count} pillars (max ${PILLARS_MAX})`,
306
+ report.stale_questions.length &&
307
+ `${report.stale_questions.length} open question(s) unanswered for over ` +
308
+ `${report.stale_questions[0].limit} commits`,
222
309
  Object.keys(report.linter).length && 'П2 linter findings in systems docs',
223
310
  report.index_drift && 'INDEX drift (regenerate)',
224
311
  report.design?.blocking && `${report.design.blocking} blocking design finding(s)`,
@@ -226,7 +313,7 @@ const docAlerts = (report) => [
226
313
 
227
314
  /** @param {AuditReport} report */
228
315
  function firstLine(report) {
229
- const alerts = [...cardAlerts(report), ...docAlerts(report)].filter(Boolean);
316
+ const alerts = [...itemAlerts(report), ...docAlerts(report)].filter(Boolean);
230
317
 
231
318
  return alerts.length ? alerts.join('; ') : 'clean';
232
319
  }
package/lib/bundle.js CHANGED
@@ -147,14 +147,17 @@ async function currentParts(root) {
147
147
 
148
148
  /** A design file with the `## <id>` section replaced by `text`, or appended when absent. */
149
149
  function putUnit(fileText, id, text) {
150
- const lines = fileText.split('\n');
150
+ // A file kept with CRLF stays CRLF: work in LF, write back in the file's own line ending.
151
+ const eol = fileText.match(/\r?\n/)?.[0] ?? '\n';
152
+ const lf = fileText.replace(/\r\n/g, '\n');
153
+ const lines = lf.split('\n');
151
154
  const at = lines.findIndex((l) => l.trimEnd() === `## ${id}`);
152
- const section = [`## ${id}`, '', ...text.split('\n'), ''];
153
- if (at < 0) return `${fileText.trimEnd()}\n\n${section.join('\n')}`;
155
+ const section = [`## ${id}`, '', ...text.replace(/\r\n/g, '\n').split('\n'), ''];
156
+ if (at < 0) return `${lf.trimEnd()}\n\n${section.join('\n')}`.replace(/\n/g, eol);
154
157
  let end = lines.findIndex((l, i) => i > at && /^## \S/.test(l));
155
158
  if (end < 0) end = lines.length;
156
159
 
157
- return [...lines.slice(0, at), ...section, ...lines.slice(end)].join('\n');
160
+ return [...lines.slice(0, at), ...section, ...lines.slice(end)].join('\n').replace(/\n/g, eol);
158
161
  }
159
162
 
160
163
  /** A design file with its preamble purpose replaced; a title of the file's own stays. */