gcf-aif-ext 0.3.0 → 0.4.1
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 +63 -11
- package/commands/gcf.js +321 -43
- package/extension.json +22 -5
- package/hooks/pre-commit.js +60 -31
- package/injections/commit-status.md +7 -5
- package/injections/plan-context.md +8 -5
- package/injections/review-pillars.md +4 -3
- package/lib/agents-block.js +66 -0
- package/lib/anchors.js +27 -4
- package/lib/audit-core.js +135 -48
- package/lib/bundle.js +7 -4
- package/lib/context.js +33 -25
- package/lib/design-check.js +149 -40
- package/lib/design.js +2 -2
- package/lib/gcf-paths.js +13 -2
- package/lib/id.js +2 -2
- package/lib/inbox.js +54 -0
- package/lib/index-gen.js +23 -8
- package/lib/ledger.js +19 -17
- package/lib/linter.js +2 -1
- package/lib/live.js +191 -0
- package/lib/pillars.js +113 -0
- package/lib/plan-check.js +31 -0
- package/lib/playtest-list.js +8 -4
- package/lib/systems-doc.js +16 -1
- package/lib/transitions.js +36 -21
- package/lib/unit-ops.js +125 -97
- package/lib/validate-item.js +244 -0
- package/lib/verdict-fs.js +67 -128
- package/lib/work-ops.js +759 -0
- package/package.json +4 -3
- package/profiles/pillars.md +1 -1
- package/profiles/system.md +1 -1
- package/profiles/unit-constraint.md +10 -0
- package/profiles/unit-entity.md +10 -0
- package/profiles/unit-mechanic.md +6 -1
- package/profiles/unit-resource.md +6 -1
- package/profiles/unit-rule.md +10 -0
- package/schemas/item.schema.json +105 -0
- package/skills/gcf-adopt/SKILL.md +2 -2
- package/skills/gcf-audit/SKILL.md +3 -3
- package/skills/gcf-card/SKILL.md +27 -20
- package/skills/gcf-ingest/SKILL.md +3 -3
- package/skills/gcf-init/SKILL.md +8 -5
- package/skills/gcf-triage/SKILL.md +35 -0
- package/skills/gcf-verdict/SKILL.md +19 -16
- package/templates/agents-block.md +28 -0
- package/templates/config.yml +7 -0
- package/templates/{card.md → item.md} +10 -3
- package/lib/validate-card.js +0 -93
- package/schemas/card.schema.json +0 -46
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 {
|
|
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
|
-
|
|
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}>}
|
|
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 {{
|
|
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,
|
|
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,
|
|
41
|
-
*
|
|
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
|
|
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
|
-
|
|
62
|
+
stale_waiting: [],
|
|
53
63
|
tune_limit: [],
|
|
54
64
|
open_depends: [],
|
|
55
|
-
wip: wipOf(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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-
|
|
86
|
-
*
|
|
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
|
|
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
|
|
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
|
|
95
|
-
if (!
|
|
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.
|
|
121
|
+
if (stale) report.stale_waiting.push(stale);
|
|
101
122
|
|
|
102
|
-
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
155
|
-
const tuned =
|
|
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
|
|
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
|
|
184
|
-
function untilRecords(
|
|
185
|
-
const fired = verdictMatchers(projectDir, ledger,
|
|
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
|
-
...
|
|
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
|
|
210
|
-
const
|
|
211
|
-
report.
|
|
212
|
-
|
|
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}
|
|
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 = [...
|
|
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
|
-
|
|
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 `${
|
|
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. */
|
package/lib/context.js
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
4
|
+
import { listItems, listSystems, readConfig, tokensEst } from './gcf-paths.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Readset assembly — the whole command is deterministic (GCF-PRD §9.1, §10.1):
|
|
8
|
-
* PILLARS + touched systems + the
|
|
8
|
+
* PILLARS + LIVE.md + touched systems + the item + its parent + its depends + graveyard tag hits +
|
|
9
9
|
* D2 assumptions whose sys overlaps. Never over_budget-throws: the caller gates.
|
|
10
10
|
*/
|
|
11
|
-
export async function buildContext(projectDir,
|
|
11
|
+
export async function buildContext(projectDir, itemId) {
|
|
12
12
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
13
13
|
const config = await readConfig(root);
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
if (!
|
|
14
|
+
const items = await listItems(root);
|
|
15
|
+
const item = items.find((c) => c.data.id === itemId);
|
|
16
|
+
if (!item) throw new Error(`Work item not found: ${itemId}`);
|
|
17
17
|
|
|
18
|
-
const systems =
|
|
19
|
-
const files = await readsetFiles(root,
|
|
20
|
-
const
|
|
21
|
-
`${
|
|
22
|
-
const graveyard_hits = await graveyardHits(root,
|
|
18
|
+
const systems = item.data.system ?? [];
|
|
19
|
+
const files = await readsetFiles(root, item, items);
|
|
20
|
+
const itemText =
|
|
21
|
+
`${item.data.id} ${item.data.title ?? ''} ${systems.join(' ')} ${item.body}`.toLowerCase();
|
|
22
|
+
const graveyard_hits = await graveyardHits(root, itemText);
|
|
23
23
|
const d2 = await d2Assumptions(root, systems);
|
|
24
24
|
|
|
25
25
|
const extraTokens = tokensEst(graveyard_hits.join('\n')) + tokensEst(d2.join('\n'));
|
|
26
26
|
const total_tokens_est = files.reduce((sum, f) => sum + f.tokens_est, 0) + extraTokens;
|
|
27
27
|
|
|
28
28
|
return {
|
|
29
|
-
|
|
29
|
+
item: itemId,
|
|
30
30
|
files,
|
|
31
31
|
graveyard_hits,
|
|
32
32
|
d2,
|
|
@@ -36,33 +36,41 @@ export async function buildContext(projectDir, cardId) {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* PILLARS, LIVE.md, touched systems, the item, its parent feature (the goal a child serves),
|
|
41
|
+
* its depends — in order, skipping absent files.
|
|
42
|
+
*/
|
|
43
|
+
async function readsetFiles(root, item, items) {
|
|
41
44
|
const files = [];
|
|
42
45
|
const push = async (rel) => {
|
|
43
46
|
const abs = path.join(root, rel);
|
|
44
|
-
|
|
47
|
+
const at = `gcf/${rel.replaceAll('\\', '/')}`;
|
|
48
|
+
// A parent that is also a dependency is read once and counted once against the budget.
|
|
49
|
+
if (!existsSync(abs) || files.some((f) => f.path === at)) return;
|
|
45
50
|
const text = await readFile(abs, 'utf8');
|
|
46
|
-
files.push({ path:
|
|
51
|
+
files.push({ path: at, tokens_est: tokensEst(text) });
|
|
47
52
|
};
|
|
48
53
|
|
|
49
54
|
await push('PILLARS.md');
|
|
50
|
-
|
|
55
|
+
await push('LIVE.md');
|
|
56
|
+
const systems = item.data.system ?? [];
|
|
51
57
|
for (const s of (await listSystems(root)).filter((d) => systems.includes(d.data.system))) {
|
|
52
58
|
await push(`systems/${s.name}`);
|
|
53
59
|
}
|
|
54
60
|
|
|
55
|
-
await push(`
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
await push(`work/${item.name}`);
|
|
62
|
+
const parent = items.find((c) => c.data.id === item.data.parent);
|
|
63
|
+
if (parent) await push(`work/${parent.name}`);
|
|
64
|
+
for (const depId of item.data.depends ?? []) {
|
|
65
|
+
const dep = items.find((c) => c.data.id === depId);
|
|
66
|
+
if (dep) await push(`work/${dep.name}`);
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
return files;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
/** Graveyard: headline tags only; a hit is a tag appearing in the
|
|
65
|
-
async function graveyardHits(root,
|
|
72
|
+
/** Graveyard: headline tags only; a hit is a tag appearing in the item's own text. */
|
|
73
|
+
async function graveyardHits(root, itemText) {
|
|
66
74
|
const gyFile = path.join(root, 'graveyard.md');
|
|
67
75
|
if (!existsSync(gyFile)) return [];
|
|
68
76
|
const hits = [];
|
|
@@ -73,13 +81,13 @@ async function graveyardHits(root, cardText) {
|
|
|
73
81
|
.split(',')
|
|
74
82
|
.map((t) => t.trim().toLowerCase())
|
|
75
83
|
.filter(Boolean);
|
|
76
|
-
if (tags.some((t) =>
|
|
84
|
+
if (tags.some((t) => itemText.includes(t))) hits.push(line.trim());
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
return hits;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
|
-
/** D2 assumptions whose sys: overlaps the
|
|
90
|
+
/** D2 assumptions whose sys: overlaps the item's systems (GCF-PRD §9.1). */
|
|
83
91
|
async function d2Assumptions(root, systems) {
|
|
84
92
|
const oqFile = path.join(root, 'open-questions.md');
|
|
85
93
|
if (!existsSync(oqFile)) return [];
|