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/design-check.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from './design.js';
|
|
19
19
|
import { buildLedger } from './ledger.js';
|
|
20
20
|
import { sourceBodies } from './anchors.js';
|
|
21
|
+
import { collapseLines } from './verdict-fs.js';
|
|
21
22
|
|
|
22
23
|
// Units a number may carry. A project adds its own in the `units:` block of glossary.md.
|
|
23
24
|
// Bare `px` is absent on purpose: its size depends on the screen density.
|
|
@@ -34,6 +35,7 @@ const COMPARISON_RE = new RegExp(
|
|
|
34
35
|
'(?:\\s+(\\S+)(?: per \\S+)?)?$', // and its unit, closing the line
|
|
35
36
|
);
|
|
36
37
|
const ENVELOPE_BLOCKS = ['feel', 'sources', 'sinks'];
|
|
38
|
+
const OPERATOR_BLOCKS = ['tuning', 'notes'];
|
|
37
39
|
const FLAGS = ['src-changed', 'orphan', 'impl?'];
|
|
38
40
|
const REQUIRED_HEADER = ['type', 'route', 'system', 'milestone', 'pillars', 'depends', 'source'];
|
|
39
41
|
const ws = (t) => String(t).replace(/\s+/g, ' ').trim();
|
|
@@ -116,7 +118,8 @@ function itemsIn(text, key) {
|
|
|
116
118
|
return at ? itemsOf(lines, at.line) : [];
|
|
117
119
|
}
|
|
118
120
|
|
|
119
|
-
const recordsIn = (text, key) =>
|
|
121
|
+
export const recordsIn = (text, key) =>
|
|
122
|
+
readRecords(itemsIn(text, key)).records.map((r) => r.values);
|
|
120
123
|
|
|
121
124
|
/** `blocks:` of open-questions.md: every reference, and the D1 ones per blocked id. */
|
|
122
125
|
export function questionBlocks(text) {
|
|
@@ -172,9 +175,14 @@ const HEADER_RULES = {
|
|
|
172
175
|
|
|
173
176
|
return [
|
|
174
177
|
'M1',
|
|
175
|
-
`a ${u.header.type} is ${soft ? 'soft' : 'hard'}: only mechanic and resource
|
|
178
|
+
`a ${u.header.type} is ${soft ? 'soft' : 'hard'}: only mechanic and resource wait for a ` +
|
|
179
|
+
'verdict by default',
|
|
176
180
|
];
|
|
177
181
|
},
|
|
182
|
+
reopens: (v, u, ctx, ledger) =>
|
|
183
|
+
ledger.where(v).terminal.length
|
|
184
|
+
? null
|
|
185
|
+
: ['M2', `reopens names ${v}, which has not ended (no history or graveyard line)`],
|
|
178
186
|
system: (v, u) => {
|
|
179
187
|
const list = listOf(v);
|
|
180
188
|
if (list === 'TBD' || !list.length || !list.every((s) => SLUG_RE.test(s)))
|
|
@@ -400,36 +408,84 @@ function preambleIssues(design) {
|
|
|
400
408
|
}));
|
|
401
409
|
}
|
|
402
410
|
|
|
411
|
+
/** M2 over a work item's references: depends, parent, children, reopens. */
|
|
412
|
+
function itemRefIssues(item, ledger, add) {
|
|
413
|
+
const d = item.data;
|
|
414
|
+
const unknown = (d.depends ?? []).filter((x) => x === d.id || !ledger.known(x));
|
|
415
|
+
if (unknown.length) add('M2', 1, `depends on unknown ${unknown.join(', ')}`);
|
|
416
|
+
// A parent that already ended is fine: a parked bug may outlive its feature.
|
|
417
|
+
const parent = d.parent ? ledger.item(d.parent) : null;
|
|
418
|
+
if (d.parent && !(parent ? parent.data.type === 'feature' : ledger.known(d.parent)))
|
|
419
|
+
add('M2', 1, `parent ${d.parent} is neither a feature item nor an ended id`);
|
|
420
|
+
const lost = (d.children ?? []).filter((c) => !ledger.known(c));
|
|
421
|
+
if (lost.length) add('M2', 1, `children unknown: ${lost.join(', ')}`);
|
|
422
|
+
if (d.reopens && !ledger.where(d.reopens).terminal.length)
|
|
423
|
+
add('M2', 1, `reopens names ${d.reopens}, which has not ended (no history or graveyard line)`);
|
|
424
|
+
}
|
|
425
|
+
|
|
403
426
|
/**
|
|
404
|
-
* A
|
|
405
|
-
* `contract` and `interactions` written.
|
|
427
|
+
* A work item. A design-unit item carries the unit's body: the same profile, plus what work
|
|
428
|
+
* owes — no TBD left, `contract` and `interactions` written. Every item's references are known.
|
|
429
|
+
* Its frontmatter is validated by the schema separately.
|
|
406
430
|
*/
|
|
407
|
-
async function
|
|
431
|
+
async function checkItem(item, ctx, ledger) {
|
|
408
432
|
/** @type {Issue[]} */
|
|
409
433
|
const issues = [];
|
|
410
|
-
const file = `
|
|
411
|
-
const raw = await readFile(
|
|
412
|
-
const offset = raw.split('\n').length -
|
|
434
|
+
const file = `work/${item.name}`;
|
|
435
|
+
const raw = await readFile(item.file, 'utf8');
|
|
436
|
+
const offset = raw.split('\n').length - item.body.split('\n').length;
|
|
413
437
|
const add = (check, line, message, severity = 'block') =>
|
|
414
|
-
issues.push({ check, severity, file, line: line + offset, id:
|
|
415
|
-
|
|
438
|
+
issues.push({ check, severity, file, line: line + offset, id: item.data.id, message });
|
|
439
|
+
itemRefIssues(item, ledger, add);
|
|
440
|
+
if (!UNIT_TYPES.includes(item.data.type)) return issues;
|
|
416
441
|
|
|
417
|
-
const { profile, issues: shape } = await bodyProblems(
|
|
442
|
+
const { profile, issues: shape } = await bodyProblems(item.body, item.data.type);
|
|
418
443
|
for (const i of shape) add('M1', i.line, i.message);
|
|
419
|
-
|
|
444
|
+
// `tuning:` and `notes:` are the operator's words to the next build, not design slots: a
|
|
445
|
+
// "dash 20% shorter" there is a remark, not a number outside an envelope.
|
|
446
|
+
const values = slotValues(item.body, profile).filter((v) => !OPERATOR_BLOCKS.includes(v.block));
|
|
420
447
|
for (const v of values.filter((x) => isTbd(x.value)))
|
|
421
|
-
add('M1', v.line, `a
|
|
448
|
+
add('M1', v.line, `a unit in work carries no TBD: ${v.block}`);
|
|
422
449
|
for (const [c, line, m] of valueIssues(values, ctx)) add(c, line, m);
|
|
423
|
-
for (const r of provenanceOf(
|
|
450
|
+
for (const r of provenanceOf(item.body)) {
|
|
424
451
|
const problem = recordProblem(r, ctx);
|
|
425
|
-
//
|
|
452
|
+
// An item's quotes go stale when the GDD moves on: that is a D1 for the human, not a gate.
|
|
426
453
|
if (problem) add('M9', r.line, problem, isDrift(problem) ? 'report' : 'block');
|
|
427
454
|
}
|
|
428
455
|
|
|
429
456
|
return issues;
|
|
430
457
|
}
|
|
431
458
|
|
|
432
|
-
/**
|
|
459
|
+
/**
|
|
460
|
+
* M15: an unplayed item's lines are all in its systems docs — the body is what a tune or a kill
|
|
461
|
+
* takes back out (Methodology §5.4), so a line edited away there could never leave.
|
|
462
|
+
*/
|
|
463
|
+
async function unplayedIssues(root, ledger) {
|
|
464
|
+
/** @type {Issue[]} */
|
|
465
|
+
const out = [];
|
|
466
|
+
for (const item of ledger.items.filter((i) => i.data.status === 'unplayed')) {
|
|
467
|
+
for (const s of item.data.system ?? []) {
|
|
468
|
+
const text = await readIf(path.join(root, 'systems', `${s}.md`));
|
|
469
|
+
const doc = String(text ?? '').replace(/\r\n/g, '\n');
|
|
470
|
+
const missing = Object.values(collapseLines(item.body))
|
|
471
|
+
.flat()
|
|
472
|
+
.find((l) => !doc.split('\n').includes(l));
|
|
473
|
+
if (missing)
|
|
474
|
+
out.push({
|
|
475
|
+
check: 'M15',
|
|
476
|
+
severity: 'block',
|
|
477
|
+
file: `work/${item.name}`,
|
|
478
|
+
line: 1,
|
|
479
|
+
id: item.data.id,
|
|
480
|
+
message: `unplayed ${item.data.id}: systems/${s}.md lost its line "${missing}"`,
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** M12: one live place per id, and no return from a terminal state — ids are never reused. */
|
|
433
489
|
function ledgerIssues(ledger) {
|
|
434
490
|
/** @type {Issue[]} */
|
|
435
491
|
const out = [];
|
|
@@ -440,10 +496,15 @@ function ledgerIssues(ledger) {
|
|
|
440
496
|
out.push({ check: 'M12', severity: 'block', file, line: 1, id, message });
|
|
441
497
|
if (live.length > 1)
|
|
442
498
|
add(`${id} lives in ${live.length} places: ${live.map((p) => p.file).join(', ')}`);
|
|
443
|
-
|
|
499
|
+
// An id ends once; two ends (a merge of branches that each ended it) leave its dependents
|
|
500
|
+
// counting it as played or dead depending on which line wins.
|
|
501
|
+
if (terminal.length > 1)
|
|
502
|
+
add(`${id} ended ${terminal.join(' and ')}: keep one line in history.md or graveyard.md`);
|
|
503
|
+
if (live.length && terminal.length) {
|
|
444
504
|
add(
|
|
445
|
-
`${id} is ${terminal.join(' and ')} already:
|
|
446
|
-
|
|
505
|
+
`${id} is ${terminal.join(' and ')} already: ids are never reused — give the new one ` +
|
|
506
|
+
`its own id and "reopens: ${id}" (an undone move: take its line out of history.md ` +
|
|
507
|
+
'or graveyard.md)',
|
|
447
508
|
);
|
|
448
509
|
}
|
|
449
510
|
}
|
|
@@ -451,11 +512,47 @@ function ledgerIssues(ledger) {
|
|
|
451
512
|
return out;
|
|
452
513
|
}
|
|
453
514
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
515
|
+
/**
|
|
516
|
+
* M2 over `depends:` of units and items together: a cycle never gets played — every id in it
|
|
517
|
+
* waits for the next one. Each cycle is reported once, from the id DFS meets it at.
|
|
518
|
+
*/
|
|
519
|
+
export function dependsCycles(ledger) {
|
|
520
|
+
/** @type {Map<string, string[]>} */
|
|
521
|
+
const graph = new Map();
|
|
522
|
+
for (const u of ledger.design.flatMap((d) => d.units)) {
|
|
523
|
+
const list = listOf(u.header.depends ?? '');
|
|
524
|
+
graph.set(u.id, Array.isArray(list) ? list : []);
|
|
525
|
+
}
|
|
457
526
|
|
|
458
|
-
|
|
527
|
+
for (const c of ledger.items) graph.set(String(c.data.id), c.data.depends ?? []);
|
|
528
|
+
/** @type {Issue[]} */
|
|
529
|
+
const out = [];
|
|
530
|
+
const state = new Map(); // id → 'open' while on the DFS path, 'done' after
|
|
531
|
+
const visit = (id, trail) => {
|
|
532
|
+
if (state.get(id) === 'done') return;
|
|
533
|
+
if (state.get(id) === 'open') {
|
|
534
|
+
const cycle = [...trail.slice(trail.indexOf(id)), id];
|
|
535
|
+
const file = ledger.where(id).live[0]?.file ?? 'design';
|
|
536
|
+
out.push({
|
|
537
|
+
check: 'M2',
|
|
538
|
+
severity: 'block',
|
|
539
|
+
file,
|
|
540
|
+
line: 1,
|
|
541
|
+
id,
|
|
542
|
+
message: `depends cycle ${cycle.join(' -> ')}: none of them can be played first`,
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
state.set(id, 'open');
|
|
549
|
+
for (const d of graph.get(id) ?? []) visit(d, [...trail, id]);
|
|
550
|
+
state.set(id, 'done');
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
for (const id of graph.keys()) visit(id, []);
|
|
554
|
+
|
|
555
|
+
return out;
|
|
459
556
|
}
|
|
460
557
|
|
|
461
558
|
/** M14: backlog.md is the order of the units in design/, each exactly once. */
|
|
@@ -527,6 +624,31 @@ function pillarReport(units, ctx) {
|
|
|
527
624
|
};
|
|
528
625
|
}
|
|
529
626
|
|
|
627
|
+
/** A unit's line in the report: its TBD count and whether it is ready to take. */
|
|
628
|
+
function unitSummary(u, own, values, ctx) {
|
|
629
|
+
const pillars = listOf(u.header.pillars ?? '');
|
|
630
|
+
// Once PILLARS.md exists, a mechanic or resource says which pillar it serves before work:
|
|
631
|
+
// `take` would turn the TBD into an empty list and the unit would serve nothing silently.
|
|
632
|
+
const pillarsTbd =
|
|
633
|
+
pillars === 'TBD' && ctx.pillarIds.length && CARD_TYPES.includes(u.header.type) ? 1 : 0;
|
|
634
|
+
const tbd = values.filter((v) => isTbd(v.value)).length + pillarsTbd;
|
|
635
|
+
const blockedBy = ctx.questions.d1.get(u.id) ?? [];
|
|
636
|
+
|
|
637
|
+
return {
|
|
638
|
+
id: u.id,
|
|
639
|
+
file: u.file,
|
|
640
|
+
system: u.system,
|
|
641
|
+
type: u.header.type ?? null,
|
|
642
|
+
milestone: u.header.milestone ?? 'TBD',
|
|
643
|
+
pillars,
|
|
644
|
+
pillars_owed: Boolean(pillarsTbd),
|
|
645
|
+
values: values.length,
|
|
646
|
+
tbd,
|
|
647
|
+
blocked_by: blockedBy,
|
|
648
|
+
ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
530
652
|
/**
|
|
531
653
|
* Run every design check over a project. `blocking` counts the issues that refuse a commit;
|
|
532
654
|
* the rest of the report (completeness, readiness, pillars) is a reading, not a gate.
|
|
@@ -541,27 +663,14 @@ export async function designCheck(projectDir) {
|
|
|
541
663
|
for (const u of ledger.design.flatMap((d) => d.units)) {
|
|
542
664
|
const { issues: own, values } = await checkUnit(u, ctx, ledger);
|
|
543
665
|
issues.push(...own);
|
|
544
|
-
|
|
545
|
-
const blockedBy = ctx.questions.d1.get(u.id) ?? [];
|
|
546
|
-
const pillars = listOf(u.header.pillars ?? '');
|
|
547
|
-
units.push({
|
|
548
|
-
id: u.id,
|
|
549
|
-
file: u.file,
|
|
550
|
-
system: u.system,
|
|
551
|
-
type: u.header.type ?? null,
|
|
552
|
-
milestone: u.header.milestone ?? 'TBD',
|
|
553
|
-
pillars,
|
|
554
|
-
values: values.length,
|
|
555
|
-
tbd,
|
|
556
|
-
blocked_by: blockedBy,
|
|
557
|
-
ready: tbd === 0 && !blockedBy.length && !own.some((i) => i.severity === 'block'),
|
|
558
|
-
});
|
|
666
|
+
units.push(unitSummary(u, own, values, ctx));
|
|
559
667
|
}
|
|
560
668
|
|
|
561
|
-
for (const c of ledger.
|
|
669
|
+
for (const c of ledger.items) issues.push(...(await checkItem(c, ctx, ledger)));
|
|
670
|
+
issues.push(...(await unplayedIssues(root, ledger)));
|
|
562
671
|
for (const doc of ['game', 'glossary']) issues.push(...(await checkDoc(root, doc, ctx)));
|
|
563
672
|
issues.push(...preambleIssues(ledger.design));
|
|
564
|
-
issues.push(...ledgerIssues(ledger), ...questionIssues(ctx, ledger));
|
|
673
|
+
issues.push(...ledgerIssues(ledger), ...dependsCycles(ledger), ...questionIssues(ctx, ledger));
|
|
565
674
|
if (ledger.design.length) {
|
|
566
675
|
const backlog = await readIf(path.join(root, 'backlog.md'));
|
|
567
676
|
issues.push(...backlogIssues(backlog, new Set(units.map((u) => u.id))));
|
package/lib/design.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Design Model (GCF-PRD §5.7): gcf/design/<system>.md holds design units, one `## <id>`
|
|
2
|
-
// section each. A unit's body is the body its
|
|
2
|
+
// section each. A unit's body is the body its work item carries once it is taken into work,
|
|
3
3
|
// so one CNL-P profile per type serves both places.
|
|
4
4
|
import { readFile, readdir } from 'node:fs/promises';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
@@ -9,7 +9,7 @@ import { parseBlocks, itemsOf, readRecords, loadProfileAt } from 'cnlp-kit';
|
|
|
9
9
|
|
|
10
10
|
export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
11
11
|
export const UNIT_TYPES = ['mechanic', 'resource', 'rule', 'constraint', 'entity'];
|
|
12
|
-
//
|
|
12
|
+
// A soft unit waits for a verdict by default; a hard one closes by its tests.
|
|
13
13
|
export const CARD_TYPES = ['mechanic', 'resource'];
|
|
14
14
|
export const HEADER_KEYS = [
|
|
15
15
|
'type',
|
package/lib/gcf-paths.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import YAML from 'yaml';
|
|
5
5
|
import { parse } from './frontmatter.js';
|
|
@@ -13,6 +13,9 @@ const DEFAULT_CONFIG = {
|
|
|
13
13
|
context_budget: 30000,
|
|
14
14
|
pillars_budget: 1500,
|
|
15
15
|
envelope_default_spread: 0.3,
|
|
16
|
+
unplayed_warn_after: 5,
|
|
17
|
+
plan_passes: 5,
|
|
18
|
+
dod_attempts: 2,
|
|
16
19
|
};
|
|
17
20
|
|
|
18
21
|
/** `gcf/` lives at the repository root (GCF-PRD §5.1). Throws when absent. */
|
|
@@ -64,7 +67,15 @@ async function listDocs(dir) {
|
|
|
64
67
|
return out;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
export const
|
|
70
|
+
export const listItems = (root) => listDocs(path.join(root, 'work'));
|
|
71
|
+
|
|
72
|
+
/** Schema 2 card files left in gcf/cards/: GCF 0.4 reads gcf/work/ and does not migrate them. */
|
|
73
|
+
export function legacyCards(root) {
|
|
74
|
+
const dir = path.join(root, 'cards');
|
|
75
|
+
|
|
76
|
+
return existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith('.md')) : [];
|
|
77
|
+
}
|
|
78
|
+
|
|
68
79
|
export const listSystems = (root) => listDocs(path.join(root, 'systems'));
|
|
69
80
|
|
|
70
81
|
/** First bullet of a `key:` block in a CNL-P body, for one-line descriptions. */
|
package/lib/id.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
|
|
3
3
|
// GCF-PRD §5.8: a unit's id is a slug, one for every stage — the `## <id>` section in design/,
|
|
4
|
-
// the
|
|
4
|
+
// the work item work/<id>.md, the history or graveyard line, the move's commit. Frozen once made.
|
|
5
5
|
const ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
6
6
|
|
|
7
7
|
/** Derive a slug from a free-text name. Created once; never re-derived. */
|
|
@@ -19,7 +19,7 @@ export function isValidId(id) {
|
|
|
19
19
|
return ID_RE.test(String(id ?? ''));
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
/**
|
|
22
|
+
/** Work item filename invariant: `work/<id>.md`. */
|
|
23
23
|
export function stemMatches(file, id) {
|
|
24
24
|
return path.basename(file, '.md') === id;
|
|
25
25
|
}
|
package/lib/inbox.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// gcf/inbox.md (Methodology §5.1): raw input waiting for triage — playtest notes, CI failures,
|
|
2
|
+
// the agent's ideas and requests. One line per entry, `- <in-id> | <source> | <text>`; a triaged
|
|
3
|
+
// line is erased, not marked: the file holds what is still waiting, not what happened to it.
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { atomicWrite } from './safe-path.js';
|
|
9
|
+
|
|
10
|
+
export const INBOX_HEAD =
|
|
11
|
+
'# Inbox — `- <in-id> | <source> | <text>`, raw input waiting for triage; a triaged line ' +
|
|
12
|
+
'is erased\n';
|
|
13
|
+
export const SOURCES = ['playtest', 'ci', 'agent', 'operator'];
|
|
14
|
+
const LINE = /^- (in-[0-9a-f]+) \| (\S+) \| (.+)$/;
|
|
15
|
+
|
|
16
|
+
const inboxFile = (root) => path.join(root, 'inbox.md');
|
|
17
|
+
|
|
18
|
+
/** @returns {Promise<Array<{id: string, source: string, text: string}>>} */
|
|
19
|
+
export async function listInbox(root) {
|
|
20
|
+
const file = inboxFile(root);
|
|
21
|
+
if (!existsSync(file)) return [];
|
|
22
|
+
|
|
23
|
+
return (await readFile(file, 'utf8')).split(/\r?\n/).flatMap((l) => {
|
|
24
|
+
const m = l.match(LINE);
|
|
25
|
+
|
|
26
|
+
return m ? [{ id: m[1], source: m[2], text: m[3].trim() }] : [];
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Append an entry; its id hashes the text and the moment, so two branches never collide. */
|
|
31
|
+
export async function addInbox(root, text, source = 'operator', now = new Date().toISOString()) {
|
|
32
|
+
const clean = String(text).replace(/\s+/g, ' ').trim();
|
|
33
|
+
if (!clean) throw new Error('an inbox entry needs text');
|
|
34
|
+
if (!SOURCES.includes(source)) throw new Error(`source must be ${SOURCES.join(' | ')}`);
|
|
35
|
+
const id = `in-${createHash('sha1').update(`${now}\n${clean}`).digest('hex').slice(0, 8)}`;
|
|
36
|
+
const file = inboxFile(root);
|
|
37
|
+
const old = existsSync(file) ? await readFile(file, 'utf8') : INBOX_HEAD;
|
|
38
|
+
const eol = old.includes('\r\n') ? '\r\n' : '\n';
|
|
39
|
+
const head = old.endsWith('\n') ? old : old + eol;
|
|
40
|
+
await atomicWrite(file, `${head}- ${id} | ${source} | ${clean}${eol}`);
|
|
41
|
+
|
|
42
|
+
return { id, source, text: clean };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Erase entry `id`; refuses an id the inbox does not hold. */
|
|
46
|
+
export async function dropInbox(root, id) {
|
|
47
|
+
const file = inboxFile(root);
|
|
48
|
+
const text = existsSync(file) ? await readFile(file, 'utf8') : '';
|
|
49
|
+
const lines = text.split(/(?<=\n)/);
|
|
50
|
+
// Each split line keeps its own ending, and `$` without the m flag does not stop before it.
|
|
51
|
+
const kept = lines.filter((l) => l.replace(/\r?\n$/, '').match(LINE)?.[1] !== id);
|
|
52
|
+
if (kept.length === lines.length) throw new Error(`${id} is not in gcf/inbox.md`);
|
|
53
|
+
await atomicWrite(file, kept.join(''));
|
|
54
|
+
}
|
package/lib/index-gen.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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, firstBullet } from './gcf-paths.js';
|
|
5
|
+
import { parseLive } from './live.js';
|
|
5
6
|
import { atomicWrite } from './safe-path.js';
|
|
6
7
|
import { readDesign, slotValues, loadDocProfile, isTbd, UNIT_TYPES } from './design.js';
|
|
7
8
|
|
|
@@ -40,11 +41,7 @@ export async function generate(root) {
|
|
|
40
41
|
row(['system', `systems/${s.name}`, '-', s.data.system ?? '-', purpose]);
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
|
|
44
|
-
const system = (c.data.system ?? []).join(',');
|
|
45
|
-
row(['card', `cards/${c.name}`, c.data.status ?? '?', system, firstBullet(c.body, 'intent')]);
|
|
46
|
-
}
|
|
47
|
-
|
|
44
|
+
await workRows(root, row);
|
|
48
45
|
await endRows(root, row);
|
|
49
46
|
|
|
50
47
|
const txt = await readIfExists(root, 'open-questions.md');
|
|
@@ -57,11 +54,29 @@ export async function generate(root) {
|
|
|
57
54
|
return `${lines.join('\n')}\n`;
|
|
58
55
|
}
|
|
59
56
|
|
|
57
|
+
/** One row per work item, then the inbox and LIVE.md counts. */
|
|
58
|
+
async function workRows(root, row) {
|
|
59
|
+
for (const c of await listItems(root)) {
|
|
60
|
+
const system = (c.data.system ?? []).join(',') || '-';
|
|
61
|
+
const kind = `${c.data.type ?? '?'}/${c.data.gate ?? '?'}`;
|
|
62
|
+
const descr = `${kind}: ${c.data.title ?? firstBullet(c.body, 'intent')}`;
|
|
63
|
+
row(['item', `work/${c.name}`, c.data.status ?? '?', system, descr]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const inbox = await readIfExists(root, 'inbox.md');
|
|
67
|
+
if (inbox !== null)
|
|
68
|
+
row(['inbox', 'inbox.md', '-', '-', `${count(inbox, /^- in-\S+ \|/gm)} entries to triage`]);
|
|
69
|
+
const live = await readIfExists(root, 'LIVE.md');
|
|
70
|
+
if (live === null) return;
|
|
71
|
+
const { capabilities: caps, knownIssues: known } = parseLive(live);
|
|
72
|
+
row(['live', 'LIVE.md', '-', '-', `${caps.length} capabilities, ${known.length} known issues`]);
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
/** The rows of the files that record how lives ended: one count per file. */
|
|
61
76
|
async function endRows(root, row) {
|
|
62
77
|
for (const [type, what] of [
|
|
63
|
-
['history', '
|
|
64
|
-
['graveyard', 'killed
|
|
78
|
+
['history', 'ended accepted'],
|
|
79
|
+
['graveyard', 'killed or dropped'],
|
|
65
80
|
]) {
|
|
66
81
|
const text = await readIfExists(root, `${type}.md`);
|
|
67
82
|
if (text !== null) row([type, `${type}.md`, '-', '-', `${count(text, /^- \S+ \|/gm)} ${what}`]);
|
package/lib/ledger.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
// Where an id lives (
|
|
2
|
-
// an end is the line the move wrote in the commit that ended the life — history.md for
|
|
3
|
-
//
|
|
4
|
-
// a rewritten history keeps them, and a revert of the move takes
|
|
1
|
+
// Where an id lives (Methodology §2, §9): a live place is a section of design/ or a work item of
|
|
2
|
+
// work/, an end is the line the move wrote in the commit that ended the life — history.md for
|
|
3
|
+
// done, collapsed and decided, graveyard.md for every drop and kill. Files, not commit messages:
|
|
4
|
+
// a squash, a shallow clone or a rewritten history keeps them, and a revert of the move takes
|
|
5
|
+
// its line out with it.
|
|
5
6
|
import { readFile } from 'node:fs/promises';
|
|
6
7
|
import { existsSync } from 'node:fs';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { spawnSync } from 'node:child_process';
|
|
9
10
|
import { readDesign } from './design.js';
|
|
10
|
-
import {
|
|
11
|
+
import { listItems } from './gcf-paths.js';
|
|
11
12
|
|
|
12
|
-
const HISTORY_EVENTS = ['done', 'collapsed'];
|
|
13
|
+
export const HISTORY_EVENTS = ['done', 'collapsed', 'decided'];
|
|
13
14
|
|
|
14
15
|
/** `[id, second field]` of each line `- <id> | <field> | …` of a gcf file. */
|
|
15
16
|
async function recordLines(root, name) {
|
|
@@ -29,18 +30,18 @@ const gitOut = (args, cwd) => {
|
|
|
29
30
|
return res.status === 0 ? res.stdout : null;
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
/** Ids HEAD holds as a
|
|
33
|
+
/** Ids HEAD holds as a work item file or a design section. */
|
|
33
34
|
function headIds(projectDir) {
|
|
34
35
|
const files = gitOut(
|
|
35
|
-
['ls-tree', '-r', '--name-only', 'HEAD', '--', 'gcf/
|
|
36
|
+
['ls-tree', '-r', '--name-only', 'HEAD', '--', 'gcf/work', 'gcf/design'],
|
|
36
37
|
projectDir,
|
|
37
38
|
);
|
|
38
39
|
const ids = new Set();
|
|
39
|
-
// Top level only, as readDesign and
|
|
40
|
+
// Top level only, as readDesign and listItems read: a nested archive file is not a unit.
|
|
40
41
|
for (const f of (files ?? '')
|
|
41
42
|
.split('\n')
|
|
42
|
-
.filter((x) => /^gcf\/(
|
|
43
|
-
if (f.startsWith('gcf/
|
|
43
|
+
.filter((x) => /^gcf\/(work|design)\/[^/]+\.md$/.test(x))) {
|
|
44
|
+
if (f.startsWith('gcf/work/')) ids.add(path.basename(f, '.md'));
|
|
44
45
|
// `./` makes the path relative to the cwd, as ls-tree printed it.
|
|
45
46
|
else
|
|
46
47
|
for (const m of (gitOut(['show', `HEAD:./${f}`], projectDir) ?? '').matchAll(/^## (\S+)/gm))
|
|
@@ -52,23 +53,23 @@ function headIds(projectDir) {
|
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* @typedef {object} Place
|
|
55
|
-
* @property {'design' | '
|
|
56
|
+
* @property {'design' | 'item'} state
|
|
56
57
|
* @property {string} file gcf-relative
|
|
57
58
|
*/
|
|
58
59
|
|
|
59
60
|
/**
|
|
60
61
|
* Build the ledger of a project: `where(id)` answers `{ live, terminal }` — the live places
|
|
61
|
-
* (a design section, a
|
|
62
|
+
* (a design section, a work item) and the terminal events (done, collapsed, decided, killed).
|
|
62
63
|
*/
|
|
63
64
|
export async function buildLedger(projectDir) {
|
|
64
65
|
const root = path.join(path.resolve(projectDir), 'gcf');
|
|
65
66
|
const design = await readDesign(root);
|
|
66
|
-
const
|
|
67
|
+
const items = await listItems(root);
|
|
67
68
|
/** @type {Map<string, Place[]>} */
|
|
68
69
|
const live = new Map();
|
|
69
70
|
const add = (id, place) => live.set(id, [...(live.get(id) ?? []), place]);
|
|
70
71
|
for (const d of design) for (const u of d.units) add(u.id, { state: 'design', file: d.file });
|
|
71
|
-
for (const c of
|
|
72
|
+
for (const c of items) add(String(c.data.id), { state: 'item', file: `work/${c.name}` });
|
|
72
73
|
|
|
73
74
|
/** @type {Map<string, Set<string>>} */
|
|
74
75
|
const terminal = new Map();
|
|
@@ -77,13 +78,14 @@ export async function buildLedger(projectDir) {
|
|
|
77
78
|
if (HISTORY_EVENTS.includes(event)) mark(id, event);
|
|
78
79
|
for (const [id] of await recordLines(root, 'graveyard.md')) mark(id, 'killed');
|
|
79
80
|
// An id HEAD holds that has no live place and no terminal line is vanishing in this commit:
|
|
80
|
-
// it is not known, so
|
|
81
|
+
// it is not known, so an item or unit still citing it fails M2 in this very commit, and
|
|
81
82
|
// `vanished()` lets the gate warn that its reason was lost.
|
|
82
83
|
const vanished = [...headIds(projectDir)].filter((id) => !live.has(id) && !terminal.has(id));
|
|
83
84
|
|
|
84
85
|
return {
|
|
85
86
|
design,
|
|
86
|
-
|
|
87
|
+
items,
|
|
88
|
+
item: (id) => items.find((c) => c.data.id === id),
|
|
87
89
|
where: (id) => ({ id, live: live.get(id) ?? [], terminal: [...(terminal.get(id) ?? [])] }),
|
|
88
90
|
known: (id) => live.has(id) || terminal.has(id),
|
|
89
91
|
ids: () => [...new Set([...live.keys(), ...terminal.keys()])].sort(),
|
package/lib/linter.js
CHANGED
|
@@ -8,7 +8,8 @@ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
8
8
|
// a unit is any non-letter in any script: `\w` is ASCII-only, and "5 слотов" is not "5 с".
|
|
9
9
|
const UNIT_WORDS = [...UNITS, 'мс', 'с', 'сек', 'dmg', 'hp', 'px'].map(esc).join('|');
|
|
10
10
|
const UNIT_NUM = new RegExp(`\\d+(?:[.,]\\d+)?\\s?(?:${UNIT_WORDS})(?![\\p{L}\\p{N}_-])`, 'u');
|
|
11
|
-
|
|
11
|
+
// No space before the paren: "respawn the player (out of the arena)" is prose, not a call.
|
|
12
|
+
const SIGNATURE = /\w+\([^)]*\b(?:int|float|bool|string|void|var|ref|out)\b[^)]*\)/;
|
|
12
13
|
const CAMEL = /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/;
|
|
13
14
|
const ENVELOPE_FIELD = /^\s*(?:- )?(?:question|assumed|proposed):/;
|
|
14
15
|
|