@zyaiting/keelson 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.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/README_CN.md +101 -0
- package/bin/keelson.js +15 -0
- package/hooks/codebuddy-session.mjs +67 -0
- package/hooks/opencode-session.mjs +65 -0
- package/hooks/prompt-state.mjs +66 -0
- package/hooks/session-start.mjs +94 -0
- package/package.json +64 -0
- package/registry/models.json +118 -0
- package/registry/platforms.json +92 -0
- package/skills/keelson/SKILL.md +44 -0
- package/skills/keelson/references/build.md +61 -0
- package/skills/keelson/references/context.md +34 -0
- package/skills/keelson/references/debug.md +46 -0
- package/skills/keelson/references/design-lenses.md +78 -0
- package/skills/keelson/references/discover.md +70 -0
- package/skills/keelson/references/engineer.md +110 -0
- package/skills/keelson/references/frontend-delivery.md +38 -0
- package/skills/keelson/references/frontend-interaction.md +31 -0
- package/skills/keelson/references/frontend-review.md +33 -0
- package/skills/keelson/references/frontend-visual.md +31 -0
- package/skills/keelson/references/frontend.md +33 -0
- package/skills/keelson/references/handoff.md +43 -0
- package/skills/keelson/references/harness.md +54 -0
- package/skills/keelson/references/interview.md +120 -0
- package/skills/keelson/references/land.md +47 -0
- package/skills/keelson/references/model.md +29 -0
- package/skills/keelson/references/plan.md +106 -0
- package/skills/keelson/references/reconcile.md +61 -0
- package/skills/keelson/references/shape.md +86 -0
- package/skills/keelson/references/verify.md +64 -0
- package/skills/keelson/templates/GLOSSARY.md +5 -0
- package/skills/keelson/templates/INTENT.md +22 -0
- package/skills/keelson/templates/NOW.md +9 -0
- package/skills/keelson/templates/README.md +60 -0
- package/skills/keelson/templates/ROADMAP.md +12 -0
- package/skills/keelson/templates/change-quick.md +16 -0
- package/skills/keelson/templates/change.md +32 -0
- package/skills/keelson/templates/delta-spec.md +12 -0
- package/skills/keelson/templates/handoff.md +27 -0
- package/skills/keelson/templates/ledger.md +3 -0
- package/skills/keelson/templates/resident-block.md +7 -0
- package/skills/keelson/templates/rules-general.md +10 -0
- package/skills/keelson/templates/rules-index.md +5 -0
- package/skills/keelson/templates/spec.md +14 -0
- package/skills/keelson/templates/tasks.md +9 -0
- package/skills/keelson/templates/workflow.md +18 -0
- package/skills/zh/keelson/SKILL.md +46 -0
- package/skills/zh/keelson/references/build.md +61 -0
- package/skills/zh/keelson/references/context.md +34 -0
- package/skills/zh/keelson/references/debug.md +46 -0
- package/skills/zh/keelson/references/design-lenses.md +78 -0
- package/skills/zh/keelson/references/discover.md +70 -0
- package/skills/zh/keelson/references/engineer.md +110 -0
- package/skills/zh/keelson/references/frontend-delivery.md +38 -0
- package/skills/zh/keelson/references/frontend-interaction.md +31 -0
- package/skills/zh/keelson/references/frontend-review.md +33 -0
- package/skills/zh/keelson/references/frontend-visual.md +31 -0
- package/skills/zh/keelson/references/frontend.md +33 -0
- package/skills/zh/keelson/references/handoff.md +43 -0
- package/skills/zh/keelson/references/harness.md +54 -0
- package/skills/zh/keelson/references/interview.md +120 -0
- package/skills/zh/keelson/references/land.md +47 -0
- package/skills/zh/keelson/references/model.md +29 -0
- package/skills/zh/keelson/references/plan.md +106 -0
- package/skills/zh/keelson/references/reconcile.md +61 -0
- package/skills/zh/keelson/references/shape.md +86 -0
- package/skills/zh/keelson/references/verify.md +64 -0
- package/skills/zh/keelson/templates/GLOSSARY.md +5 -0
- package/skills/zh/keelson/templates/INTENT.md +22 -0
- package/skills/zh/keelson/templates/NOW.md +9 -0
- package/skills/zh/keelson/templates/README.md +60 -0
- package/skills/zh/keelson/templates/ROADMAP.md +12 -0
- package/skills/zh/keelson/templates/change-quick.md +16 -0
- package/skills/zh/keelson/templates/change.md +32 -0
- package/skills/zh/keelson/templates/delta-spec.md +12 -0
- package/skills/zh/keelson/templates/handoff.md +27 -0
- package/skills/zh/keelson/templates/ledger.md +3 -0
- package/skills/zh/keelson/templates/resident-block.md +7 -0
- package/skills/zh/keelson/templates/rules-general.md +10 -0
- package/skills/zh/keelson/templates/rules-index.md +5 -0
- package/skills/zh/keelson/templates/spec.md +14 -0
- package/skills/zh/keelson/templates/tasks.md +9 -0
- package/skills/zh/keelson/templates/workflow.md +18 -0
- package/src/cli.js +87 -0
- package/src/commands/ablate.js +96 -0
- package/src/commands/ask.js +64 -0
- package/src/commands/attest.js +71 -0
- package/src/commands/check.js +127 -0
- package/src/commands/context.js +95 -0
- package/src/commands/design.js +63 -0
- package/src/commands/doctor.js +157 -0
- package/src/commands/focus.js +84 -0
- package/src/commands/guide.js +59 -0
- package/src/commands/handoff.js +41 -0
- package/src/commands/hook.js +23 -0
- package/src/commands/impact.js +58 -0
- package/src/commands/init.js +289 -0
- package/src/commands/land.js +258 -0
- package/src/commands/models.js +62 -0
- package/src/commands/new.js +70 -0
- package/src/commands/platforms.js +39 -0
- package/src/commands/retro.js +114 -0
- package/src/commands/status.js +115 -0
- package/src/commands/uninstall.js +30 -0
- package/src/commands/validate.js +117 -0
- package/src/lib/args.js +30 -0
- package/src/lib/changes.js +114 -0
- package/src/lib/check-activity.js +29 -0
- package/src/lib/config.js +102 -0
- package/src/lib/decisions.js +59 -0
- package/src/lib/evidence.js +127 -0
- package/src/lib/fs.js +126 -0
- package/src/lib/git.js +353 -0
- package/src/lib/glob.js +54 -0
- package/src/lib/health.js +113 -0
- package/src/lib/lifecycle.js +120 -0
- package/src/lib/maintenance.js +66 -0
- package/src/lib/markdown.js +438 -0
- package/src/lib/models.js +195 -0
- package/src/lib/out.js +13 -0
- package/src/lib/paths.js +82 -0
- package/src/lib/rules.js +27 -0
- package/src/lib/runtime-path.js +22 -0
- package/src/lib/session.js +100 -0
- package/src/lib/specs.js +345 -0
- package/src/lib/transaction.js +93 -0
- package/src/platforms/index.js +3 -0
- package/src/platforms/integration.js +384 -0
- package/src/platforms/registry.js +46 -0
- package/src/platforms/runtime.js +249 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
3
|
+
import { readOr, listDirs } from '../lib/fs.js';
|
|
4
|
+
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { loadAllChanges, sharedContracts } from '../lib/changes.js';
|
|
6
|
+
import { evaluateLifecycle } from '../lib/lifecycle.js';
|
|
7
|
+
import { readSession } from '../lib/session.js';
|
|
8
|
+
import { worktreeFingerprint, headSha, lastTag, foldedSince, gitStatusShort, isGitRepo } from '../lib/git.js';
|
|
9
|
+
import { heading, dim, warn } from '../lib/out.js';
|
|
10
|
+
import { knowledgeHealth } from '../lib/health.js';
|
|
11
|
+
import { maintainRuntime } from '../lib/maintenance.js';
|
|
12
|
+
import { changeSpecDrift } from '../lib/specs.js';
|
|
13
|
+
|
|
14
|
+
export function projectStatus(root) {
|
|
15
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
16
|
+
const p = projectPaths(root, cfg);
|
|
17
|
+
const changes = loadAllChanges(p.changes);
|
|
18
|
+
const fp = worktreeFingerprint(root);
|
|
19
|
+
const head = headSha(root);
|
|
20
|
+
const tag = lastTag(root);
|
|
21
|
+
const dirty = gitStatusShort(root) ?? [];
|
|
22
|
+
const session = readSession(root);
|
|
23
|
+
const focus = session.state?.change && changes.some((c) => c.name === session.state.change) ? session.state.change : null;
|
|
24
|
+
const activeNames = new Set(changes.map((c) => c.name));
|
|
25
|
+
const rows = changes.map((c) => {
|
|
26
|
+
const contractDrift = changeSpecDrift(c, p.specs);
|
|
27
|
+
const lifecycle = evaluateLifecycle(c, fp, { activeNames, contractDrift });
|
|
28
|
+
const verification = lifecycle.verification;
|
|
29
|
+
const blockedBy = lifecycle.blockedBy;
|
|
30
|
+
const handoff = c.handoff ? { updated: c.handoff.updated, at: c.handoff.at, headMoved: Boolean(c.handoff.at && head && !head.startsWith(c.handoff.at) && !c.handoff.at.startsWith(head)), next: c.handoff.next } : null;
|
|
31
|
+
return {
|
|
32
|
+
name: c.name,
|
|
33
|
+
tier: c.tier,
|
|
34
|
+
owner: c.owner,
|
|
35
|
+
branch: c.branch,
|
|
36
|
+
work: lifecycle.work,
|
|
37
|
+
verification,
|
|
38
|
+
gates: lifecycle.gates,
|
|
39
|
+
lifecycleWarnings: lifecycle.warnings,
|
|
40
|
+
release: c.release ?? 'unreleased',
|
|
41
|
+
progress: c.progress,
|
|
42
|
+
slices: c.slices,
|
|
43
|
+
acceptance: c.acceptanceProgress,
|
|
44
|
+
open: c.open,
|
|
45
|
+
assumed: c.assumed.length,
|
|
46
|
+
capabilities: c.capabilities,
|
|
47
|
+
depends: c.depends,
|
|
48
|
+
blockedBy,
|
|
49
|
+
handoff,
|
|
50
|
+
rulings: c.ledger.filter((e) => e.kind === 'ruling').length,
|
|
51
|
+
tasks: c.tasks,
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
const knowledgeFindings = knowledgeHealth(root, cfg, p).filter((h) => h.kind === 'budget' || h.kind === 'budget-hard');
|
|
55
|
+
const knowledge = {
|
|
56
|
+
critical: knowledgeFindings.filter((h) => h.level === 'error').length,
|
|
57
|
+
overBudget: knowledgeFindings.filter((h) => h.level === 'warn').length,
|
|
58
|
+
findings: knowledgeFindings.map((h) => ({ level: h.level, kind: h.kind, text: h.text })),
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
root,
|
|
62
|
+
head,
|
|
63
|
+
fingerprint: fp,
|
|
64
|
+
dirty: dirty.length,
|
|
65
|
+
specs: listDirs(p.specs),
|
|
66
|
+
specsPath: p.specsRel,
|
|
67
|
+
now: readOr(p.now).trim(),
|
|
68
|
+
focus,
|
|
69
|
+
sessionAvailable: session.available,
|
|
70
|
+
changes: rows,
|
|
71
|
+
knowledge,
|
|
72
|
+
conflicts: sharedContracts(changes),
|
|
73
|
+
release: tag ? { lastTag: tag, landedSince: foldedSince(root, tag) } : null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const GLYPH = { 'not-run': '·', passed: '✓', failed: '✗', stale: '~', partial: '?' };
|
|
78
|
+
|
|
79
|
+
export async function status({ flags }, cwd = process.cwd()) {
|
|
80
|
+
const root = requireProjectRoot(cwd);
|
|
81
|
+
maintainRuntime(root);
|
|
82
|
+
const s = projectStatus(root);
|
|
83
|
+
if (flags.json) {
|
|
84
|
+
console.log(JSON.stringify(s, null, 2));
|
|
85
|
+
return 0;
|
|
86
|
+
}
|
|
87
|
+
heading(`Keelson — ${path.basename(root)}`);
|
|
88
|
+
console.log(`${s.specs.length} capabilit${s.specs.length === 1 ? 'y' : 'ies'} in ${s.specsPath} · ${s.changes.length} active change${s.changes.length === 1 ? '' : 's'}${s.focus ? ` · focus ${s.focus}` : ''}${s.head ? ` · HEAD ${s.head}` : ''}${s.dirty ? ` · ${s.dirty} uncommitted` : ''}`);
|
|
89
|
+
console.log('');
|
|
90
|
+
if (!s.changes.length) console.log(dim('No change in flight.'));
|
|
91
|
+
for (const c of s.changes) {
|
|
92
|
+
const who = [c.owner, c.branch && c.branch !== 'main' && c.branch !== 'master' ? c.branch : null].filter(Boolean).join(' @ ');
|
|
93
|
+
console.log(`${c.name} ${dim(`[${c.tier}]`)} work: ${c.work} verify: ${GLYPH[c.verification.state]} ${c.verification.state} release: ${c.release}${who ? dim(` (${who})`) : ''}`);
|
|
94
|
+
if (c.verification.state === 'stale') console.log(dim(` ${c.verification.detail} — re-run \`keelson check --record\` before landing`));
|
|
95
|
+
if (c.blockedBy.length) console.log(dim(` depends on active: ${c.blockedBy.join(', ')}`));
|
|
96
|
+
if (c.slices.length) for (const sl of c.slices) console.log(` ${sl.done === sl.total && sl.total ? '✓' : '·'} slice ${sl.name} ${sl.done}/${sl.total}${sl.delivers ? dim(` — ${sl.delivers}`) : ''}`);
|
|
97
|
+
else for (const t of c.tasks) console.log(` ${t.done ? '✓' : '·'} ${t.id ? t.id + ' ' : ''}${t.title}${t.effort ? dim(` (${t.effort})`) : ''}`);
|
|
98
|
+
if (c.acceptance.total) console.log(dim(` acceptance ${c.acceptance.done}/${c.acceptance.total}`));
|
|
99
|
+
if (c.open.length) console.log(` open: ${c.open.map((o) => `${o.text}${o.blocks.length ? ` (blocks ${o.blocks.join(', ')})` : ''}`).join('; ')}`);
|
|
100
|
+
if (c.assumed) console.log(dim(` ${c.assumed} assumed decision${c.assumed > 1 ? 's' : ''} awaiting the owner`));
|
|
101
|
+
if (c.handoff) console.log(dim(` handoff ${c.handoff.updated ?? ''}${c.handoff.at ? ` at ${c.handoff.at}` : ''}${c.handoff.headMoved ? ' — HEAD moved since; check the worktree before resuming' : ''}`));
|
|
102
|
+
}
|
|
103
|
+
if (s.conflicts.length) {
|
|
104
|
+
console.log('');
|
|
105
|
+
for (const k of s.conflicts) warn(`shared contract: ${k.a} and ${k.b} both touch ${[...k.capabilities.map((c) => `specs/${c}`), ...k.paths].join(', ')} — align the interface before implementing both`);
|
|
106
|
+
}
|
|
107
|
+
if (s.release) {
|
|
108
|
+
console.log('');
|
|
109
|
+
console.log(dim(`last release ${s.release.lastTag}; landed since: ${s.release.landedSince.length ? s.release.landedSince.join(', ') : 'none'}`));
|
|
110
|
+
}
|
|
111
|
+
console.log('');
|
|
112
|
+
heading('NOW.md');
|
|
113
|
+
console.log(s.now || dim('(empty)'));
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
2
|
+
import { rmrf, exists } from '../lib/fs.js';
|
|
3
|
+
import { loadConfig } from '../lib/config.js';
|
|
4
|
+
import { readManagedState, removeCanonicalRuntime, removeSurfaces } from '../platforms/index.js';
|
|
5
|
+
import { ok, warn, heading, info } from '../lib/out.js';
|
|
6
|
+
|
|
7
|
+
/** Remove generated surfaces (skills, resident blocks, hooks). Project facts in .keelson/ stay unless --purge. */
|
|
8
|
+
export async function uninstall({ flags }, cwd = process.cwd()) {
|
|
9
|
+
const root = requireProjectRoot(cwd);
|
|
10
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
11
|
+
const p = projectPaths(root, cfg);
|
|
12
|
+
const managed = readManagedState(root);
|
|
13
|
+
heading(`Uninstall Keelson surfaces from ${root}`);
|
|
14
|
+
const removed = removeSurfaces(root, cfg.tools ?? [], cfg);
|
|
15
|
+
for (const r of removed) ok(`removed ${r}`);
|
|
16
|
+
if (managed?.vendor === true) {
|
|
17
|
+
const canonical = removeCanonicalRuntime(root, { lang: cfg.lang, profile: cfg.profile, version: managed.packageVersion, guide: cfg.guide });
|
|
18
|
+
for (const r of canonical.removed) ok(`removed ${r}`);
|
|
19
|
+
for (const r of canonical.preserved) warn(`kept ${r}: it differs from the vendored Keelson output`);
|
|
20
|
+
}
|
|
21
|
+
if (exists(p.runtime)) {
|
|
22
|
+
rmrf(p.runtime);
|
|
23
|
+
ok('removed Keelson local runtime');
|
|
24
|
+
}
|
|
25
|
+
if (flags.purge) {
|
|
26
|
+
rmrf(p.keelson);
|
|
27
|
+
warn('removed .keelson/ entirely (INTENT, NOW, rules, changes). Specs outside .keelson/ are untouched.');
|
|
28
|
+
} else info('kept .keelson/ project facts (INTENT, NOW, ROADMAP, rules, specs, changes). Pass --purge to remove everything.');
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { requireProjectRoot, projectPaths } from '../lib/paths.js';
|
|
3
|
+
import { exists, readOr, listDirs, walk, read } from '../lib/fs.js';
|
|
4
|
+
import { loadConfig } from '../lib/config.js';
|
|
5
|
+
import { parseRulesIndex } from '../lib/rules.js';
|
|
6
|
+
import { parseSpec, parseDelta, parseFrontmatter, renderSpec, hasScenario, normalizeNewlines, requirementKey, hasSection, EFFORT_TIERS, ROOT_CAUSES, WORK_STATUSES } from '../lib/markdown.js';
|
|
7
|
+
import { loadAllChanges } from '../lib/changes.js';
|
|
8
|
+
import { datedIdPatterns } from '../lib/models.js';
|
|
9
|
+
import { knowledgeHealth } from '../lib/health.js';
|
|
10
|
+
import { readCapabilitySpec } from '../lib/specs.js';
|
|
11
|
+
import { ok, fail, warn } from '../lib/out.js';
|
|
12
|
+
|
|
13
|
+
export function validateProject(root) {
|
|
14
|
+
const errors = [];
|
|
15
|
+
const warnings = [];
|
|
16
|
+
const cfg = loadConfig(projectPaths(root).config);
|
|
17
|
+
const p = projectPaths(root, cfg);
|
|
18
|
+
|
|
19
|
+
if (!exists(p.intent)) errors.push('missing .keelson/INTENT.md');
|
|
20
|
+
else if (/^One paragraph\./m.test(readOr(p.intent)) && !/^First contact with /m.test(readOr(p.now))) warnings.push('INTENT.md still contains template placeholder text (the agent drafts it on first contact; if that already happened, ask it to)');
|
|
21
|
+
if (!exists(p.now)) errors.push('missing .keelson/NOW.md');
|
|
22
|
+
if (!['lean', 'guided'].includes(cfg.profile)) errors.push(`config.profile must be lean|guided (got ${cfg.profile})`);
|
|
23
|
+
if (!['fold', 'keep'].includes(cfg.land)) errors.push(`config.land must be fold|keep (got ${cfg.land})`);
|
|
24
|
+
if (!exists(p.specs) && p.specsRel !== '.keelson/specs') warnings.push(`paths.specs points at ${p.specsRel}, which does not exist`);
|
|
25
|
+
for (const [k, v] of Object.entries(cfg.refs ?? {})) if (v && !/^https?:\/\//.test(v) && !exists(path.join(root, v))) warnings.push(`refs.${k} points at ${v}, which does not exist`);
|
|
26
|
+
const idx = parseRulesIndex(readOr(p.rulesIndex));
|
|
27
|
+
for (const e of idx) if (!exists(path.join(p.rules, e.file))) errors.push(`rules/index.md references missing file: ${e.file}`);
|
|
28
|
+
for (const f of walk(p.rules)) if (f !== 'index.md' && f.endsWith('.md') && !idx.some((e) => e.file === f)) warnings.push(`rules/${f} is not listed in rules/index.md (it will never be routed)`);
|
|
29
|
+
|
|
30
|
+
for (const cap of listDirs(p.specs)) {
|
|
31
|
+
const f = path.join(p.specs, cap, 'spec.md');
|
|
32
|
+
if (!exists(f)) {
|
|
33
|
+
errors.push(`${p.specsRel}/${cap}/ has no spec.md`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const logical = readCapabilitySpec(p.specs, cap);
|
|
37
|
+
const s = parseSpec(logical);
|
|
38
|
+
if (renderSpec(s) !== normalizeNewlines(logical)) errors.push(`${p.specsRel}/${cap}/spec.md does not round-trip through the Markdown spec parser`);
|
|
39
|
+
if (!s.requirements.length) warnings.push(`${p.specsRel}/${cap}/spec.md has no Requirement sections`);
|
|
40
|
+
for (const r of s.requirements) {
|
|
41
|
+
if (!r.body.trim()) errors.push(`${p.specsRel}/${cap}: requirement "${r.name}" has an empty body`);
|
|
42
|
+
if (!hasScenario(r.body)) warnings.push(`${p.specsRel}/${cap}: requirement "${r.name}" has no scenario`);
|
|
43
|
+
}
|
|
44
|
+
const names = s.requirements.map((r) => requirementKey(r.name));
|
|
45
|
+
for (const n of new Set(names.filter((n, i) => names.indexOf(n) !== i))) errors.push(`${p.specsRel}/${cap}: duplicate requirement "${n}"`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const active = loadAllChanges(p.changes);
|
|
49
|
+
for (const c of active) {
|
|
50
|
+
const tag = `changes/${c.name}`;
|
|
51
|
+
if (!['quick', 'spec'].includes(c.tier)) errors.push(`${tag}: tier must be quick|spec (got ${c.tier})`);
|
|
52
|
+
if (!WORK_STATUSES.includes(c.work)) errors.push(`${tag}: status must be one of ${WORK_STATUSES.join('|')}`);
|
|
53
|
+
for (const sec of ['Why', 'What']) if (!hasSection(c.body, sec)) errors.push(`${tag}/change.md: missing "## ${sec}"`);
|
|
54
|
+
if (c.tier === 'spec') {
|
|
55
|
+
for (const sec of ['How', 'Impact']) if (!hasSection(c.body, sec)) errors.push(`${tag}/change.md: spec tier requires "## ${sec}"`);
|
|
56
|
+
if (!c.deltaFiles.length) warnings.push(`${tag}: spec tier but no delta specs under specs/ (fine only if behaviour does not change)`);
|
|
57
|
+
if (!hasSection(c.body, 'Acceptance')) warnings.push(`${tag}/change.md: spec tier without "## Acceptance" — landing will refuse until each acceptance item maps to a check`);
|
|
58
|
+
}
|
|
59
|
+
for (const a of c.acceptance) if (!a.kind) warnings.push(`${tag}/change.md: acceptance "${a.text}" does not say how it is checked (— check: \`cmd\` | test: name | manual: how)`);
|
|
60
|
+
for (const o of c.open) if (!o.blocks.length) warnings.push(`${tag}/change.md: open question "${o.text}" does not say what it blocks (— blocks: <slice>)`);
|
|
61
|
+
for (const d of c.depends) if (!active.some((x) => x.name === d)) warnings.push(`${tag}: depends on "${d}", which is not an active change (landed, or a typo)`);
|
|
62
|
+
if (c.breaking && !c.hasRollout) warnings.push(`${tag}/change.md: **BREAKING** without "## Rollout" — landing will refuse`);
|
|
63
|
+
for (const t of c.tasks) {
|
|
64
|
+
if (t.effort && !EFFORT_TIERS.includes(t.effort)) errors.push(`${tag}/tasks.md: bad effort on "${t.title}"`);
|
|
65
|
+
if (!t.effort) warnings.push(`${tag}/tasks.md: "${t.title}" has no (effort: …) tag`);
|
|
66
|
+
}
|
|
67
|
+
for (const sl of c.slices) {
|
|
68
|
+
if (!sl.delivers) warnings.push(`${tag}/tasks.md: slice "${sl.name}" has no "Delivers:" line`);
|
|
69
|
+
if (/^(db|database|schema|backend|back-end|frontend|front-end|ui|api|model|models|storage|infra|infrastructure)( layer| only)?$/i.test(sl.name.trim())) warnings.push(`${tag}/tasks.md: slice "${sl.name}" is named after a layer; a slice should be one user-observable path through all layers (tracer bullet)`);
|
|
70
|
+
}
|
|
71
|
+
if (/\{\{\w+\}\}|^…$/m.test(c.body)) warnings.push(`${tag}/change.md still has template placeholders`);
|
|
72
|
+
for (const e of c.ledger) {
|
|
73
|
+
if (e.kind === 'verify' && (e.exit === null || !e.command)) errors.push(`${tag}/ledger.md: Verify entry "${e.title}" needs a \`command\` and "exit N"`);
|
|
74
|
+
if (e.kind === 'verify' && !e.tree) warnings.push(`${tag}/ledger.md: Verify entry "${e.title}" has no "tree <hash>", so staleness cannot be detected (use \`keelson check --record\`)`);
|
|
75
|
+
if (e.kind === 'dispatch' && e.result === null) warnings.push(`${tag}/ledger.md: Dispatch "${e.title}" has no "Result: pass|fail" line, so retro cannot count it`);
|
|
76
|
+
if (e.kind === 'root-cause' && !ROOT_CAUSES.includes(e.category)) errors.push(`${tag}/ledger.md: unknown root cause category "${e.category}"`);
|
|
77
|
+
}
|
|
78
|
+
for (const df of c.deltaFiles) {
|
|
79
|
+
const raw = read(path.join(c.dir, 'specs', df));
|
|
80
|
+
const d = parseDelta(parseFrontmatter(raw).body);
|
|
81
|
+
for (const issue of d.issues) errors.push(`${tag}/specs/${df}: malformed delta: ${issue}`);
|
|
82
|
+
const deltaRequirements = [...d.added, ...d.modified, ...d.removed];
|
|
83
|
+
const deltaNames = deltaRequirements.map((r) => requirementKey(r.name));
|
|
84
|
+
for (const n of new Set(deltaNames.filter((n, i) => deltaNames.indexOf(n) !== i))) errors.push(`${tag}/specs/${df}: duplicate delta requirement "${n}"`);
|
|
85
|
+
for (const r of [...d.added, ...d.modified]) if (!r.body.trim()) errors.push(`${tag}/specs/${df}: ${r.name} has an empty requirement body`);
|
|
86
|
+
}
|
|
87
|
+
if (c.handoff && !c.handoff.at) warnings.push(`${tag}/handoff.md has no "at:" commit; run \`keelson handoff ${c.name}\` to stamp it`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const h of knowledgeHealth(root, cfg, p)) {
|
|
91
|
+
if (h.level === 'error') errors.push(`${h.kind}: ${h.text} → ${h.fix}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const patterns = datedIdPatterns();
|
|
95
|
+
for (const f of walk(p.keelson, { ignore: ['node_modules', '.git', '.local', '.runtime'] })) {
|
|
96
|
+
if (!/\.(md|yaml|yml|json)$/.test(f) || f.startsWith('hooks/')) continue;
|
|
97
|
+
const txt = read(path.join(p.keelson, f));
|
|
98
|
+
for (const re of patterns) {
|
|
99
|
+
const m = txt.match(re);
|
|
100
|
+
if (m) errors.push(`.keelson/${f}: dated model ID "${m[0]}" — use effort tiers (light|standard|deep) or floating aliases`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { errors, warnings };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function validate({ flags }, cwd = process.cwd()) {
|
|
107
|
+
const root = requireProjectRoot(cwd);
|
|
108
|
+
const { errors, warnings } = validateProject(root);
|
|
109
|
+
if (flags.json) {
|
|
110
|
+
console.log(JSON.stringify({ ok: !errors.length, errors, warnings }, null, 2));
|
|
111
|
+
return errors.length ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
for (const w of warnings) warn(w);
|
|
114
|
+
for (const e of errors) fail(e);
|
|
115
|
+
if (!errors.length) ok(`valid${warnings.length ? ` (${warnings.length} warning${warnings.length > 1 ? 's' : ''})` : ''}`);
|
|
116
|
+
return errors.length ? 1 : 0;
|
|
117
|
+
}
|
package/src/lib/args.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const PLATFORM_FLAGS = Object.keys(require('../../registry/platforms.json').platforms);
|
|
4
|
+
const RETIRED_PLATFORM_FLAGS = ['cursor', 'copilot', 'kilo', 'antigravity', 'devin', 'qoder', 'droid', 'ohmypi', 'reasonix', 'zcode', 'trae', 'grok', 'kimi', 'snow'];
|
|
5
|
+
export const BOOLEAN_FLAGS = new Set(['all', 'list', 'irreversible', 'trust', 'vendor', 'session', 'json', 'force', 'dryRun', 'hooks', 'noHooks', 'onboard', 'refresh', 'detect', 'help', 'version', 'yes', 'keep', 'noProviders', 'quiet', 'confirmAssumptions', 'acceptDrift', 'worktree', 'purge', 'clear', 'auto', ...PLATFORM_FLAGS, ...RETIRED_PLATFORM_FLAGS]);
|
|
6
|
+
|
|
7
|
+
/** Tiny argv parser: --flag, --key value, --key=value, positionals. */
|
|
8
|
+
export function parseArgs(argv) {
|
|
9
|
+
const flags = {};
|
|
10
|
+
const positional = [];
|
|
11
|
+
for (let i = 0; i < argv.length; i++) {
|
|
12
|
+
const a = argv[i];
|
|
13
|
+
if (a === '--') {
|
|
14
|
+
positional.push(...argv.slice(i + 1));
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
if (a.startsWith('--')) {
|
|
18
|
+
const [k, v] = a.slice(2).split(/=(.*)/s);
|
|
19
|
+
const key = k.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
20
|
+
if (v !== undefined) flags[key] = v;
|
|
21
|
+
else if (i + 1 < argv.length && !argv[i + 1].startsWith('--') && !BOOLEAN_FLAGS.has(key)) flags[key] = argv[++i];
|
|
22
|
+
else flags[key] = true;
|
|
23
|
+
} else if (a.startsWith('-') && a.length === 2) {
|
|
24
|
+
flags[a[1]] = true;
|
|
25
|
+
} else positional.push(a);
|
|
26
|
+
}
|
|
27
|
+
return { flags, positional };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const list = (v) => (v === undefined || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { listDirs, readOr, exists, walk } from './fs.js';
|
|
3
|
+
import { parseFrontmatter, parseTasks, parseSlices, parseLedger, parseAcceptance, parseOpenQuestions, parseDecisions, parseHandoff, hasSection, WORK_STATUSES } from './markdown.js';
|
|
4
|
+
import { evaluateLifecycle } from './lifecycle.js';
|
|
5
|
+
import { readDecisions } from './decisions.js';
|
|
6
|
+
import { inspectEvidence } from './evidence.js';
|
|
7
|
+
import { findProjectRoot, resolveWithin } from './paths.js';
|
|
8
|
+
export { verificationStatus } from './lifecycle.js';
|
|
9
|
+
|
|
10
|
+
export const TIERS = ['quick', 'spec'];
|
|
11
|
+
|
|
12
|
+
export function listChanges(changesDir) {
|
|
13
|
+
return listDirs(changesDir).filter((d) => d !== 'archive' && exists(path.join(changesDir, d, 'change.md')));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const csv = (v) => (v ? String(v).replace(/^\[|\]$/g, '').split(',').map((s) => s.trim()).filter(Boolean) : []);
|
|
17
|
+
|
|
18
|
+
export function loadChange(changesDir, name) {
|
|
19
|
+
if (typeof name !== 'string' || !name || name === '.' || name === '..' || /[/\\\0]/.test(name)) throw new Error('invalid change name');
|
|
20
|
+
const dir = resolveWithin(changesDir, name);
|
|
21
|
+
for (const file of walk(dir)) resolveWithin(dir, file);
|
|
22
|
+
if (!exists(path.join(dir, 'change.md'))) return null;
|
|
23
|
+
const { data, body } = parseFrontmatter(readOr(path.join(dir, 'change.md')));
|
|
24
|
+
const tasksText = readOr(path.join(dir, 'tasks.md'));
|
|
25
|
+
const tasks = parseTasks(tasksText);
|
|
26
|
+
const slices = parseSlices(tasksText);
|
|
27
|
+
const ledger = parseLedger(readOr(path.join(dir, 'ledger.md')));
|
|
28
|
+
const deltaFiles = walk(path.join(dir, 'specs')).filter((f) => f.endsWith('.md'));
|
|
29
|
+
const acceptance = parseAcceptance(body);
|
|
30
|
+
const open = parseOpenQuestions(body);
|
|
31
|
+
const decisions = parseDecisions(body);
|
|
32
|
+
const handoffText = readOr(path.join(dir, 'handoff.md'), '');
|
|
33
|
+
const handoff = handoffText ? parseHandoff(handoffText) : null;
|
|
34
|
+
const done = tasks.filter((t) => t.done).length;
|
|
35
|
+
const lastVerify = ledger.filter((e) => e.kind === 'verify').at(-1) ?? null;
|
|
36
|
+
const acceptanceDone = acceptance.filter((a) => a.done).length;
|
|
37
|
+
|
|
38
|
+
// Work status: explicit frontmatter wins; otherwise derived from the artifacts.
|
|
39
|
+
const storedWork = String(data.status ?? '').toLowerCase();
|
|
40
|
+
let work = storedWork;
|
|
41
|
+
if (!WORK_STATUSES.includes(work)) {
|
|
42
|
+
if (open.length && tasks.length === 0) work = 'clarifying';
|
|
43
|
+
else if (tasks.length === 0) work = 'clarifying';
|
|
44
|
+
else if (done < tasks.length) work = 'in-progress';
|
|
45
|
+
else work = lastVerify && lastVerify.exit === 0 ? 'in-review' : 'in-progress';
|
|
46
|
+
}
|
|
47
|
+
const capabilities = new Set(deltaFiles.map((f) => path.dirname(f).replace(/\\/g, '/')).filter((c) => c !== '.'));
|
|
48
|
+
for (const d of decisions) if (d.capability) capabilities.add(d.capability);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
name,
|
|
52
|
+
dir,
|
|
53
|
+
tier: (data.tier ?? 'quick').toLowerCase(),
|
|
54
|
+
created: data.created ?? null,
|
|
55
|
+
owner: data.owner ?? null,
|
|
56
|
+
branch: data.branch ?? null,
|
|
57
|
+
worktree: data.worktree ?? null,
|
|
58
|
+
depends: csv(data.depends),
|
|
59
|
+
touches: csv(data.touches),
|
|
60
|
+
release: data.release ?? null,
|
|
61
|
+
body,
|
|
62
|
+
tasks,
|
|
63
|
+
slices,
|
|
64
|
+
ledger,
|
|
65
|
+
deltaFiles,
|
|
66
|
+
capabilities: [...capabilities],
|
|
67
|
+
acceptance,
|
|
68
|
+
acceptanceProgress: { done: acceptanceDone, total: acceptance.length },
|
|
69
|
+
open,
|
|
70
|
+
decisions,
|
|
71
|
+
assumed: decisions.filter((d) => d.state === 'assumed'),
|
|
72
|
+
handoff,
|
|
73
|
+
progress: { done, total: tasks.length },
|
|
74
|
+
work,
|
|
75
|
+
storedWork: WORK_STATUSES.includes(storedWork) ? storedWork : null,
|
|
76
|
+
lastVerify,
|
|
77
|
+
decisionRecords: readDecisions(dir).decisions,
|
|
78
|
+
evidence: inspectEvidence(findProjectRoot(dir) ?? path.resolve(changesDir, '../..'), dir),
|
|
79
|
+
hasDecisions: hasSection(body, 'Decisions'),
|
|
80
|
+
hasRollout: hasSection(body, 'Rollout'),
|
|
81
|
+
// Only a bullet that starts with **BREAKING** counts, so the template's own hint does not.
|
|
82
|
+
breaking: /^\s*[-*]\s+\*\*BREAKING\*\*/m.test(body),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function derivedWorkStatus(change, fingerprint, options = {}) {
|
|
87
|
+
return evaluateLifecycle(change, fingerprint, options).work;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function loadAllChanges(changesDir) {
|
|
91
|
+
return listChanges(changesDir).map((n) => loadChange(changesDir, n)).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Pairs of active changes that touch the same capability or the same declared paths. */
|
|
95
|
+
export function sharedContracts(changes) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (let i = 0; i < changes.length; i++) {
|
|
98
|
+
for (let j = i + 1; j < changes.length; j++) {
|
|
99
|
+
const a = changes[i];
|
|
100
|
+
const b = changes[j];
|
|
101
|
+
const caps = a.capabilities.filter((c) => b.capabilities.includes(c));
|
|
102
|
+
const paths = a.touches.filter((p) => b.touches.some((q) => p === q || p.startsWith(q.replace(/\/?\*\*$/, '')) || q.startsWith(p.replace(/\/?\*\*$/, ''))));
|
|
103
|
+
if (caps.length || paths.length) out.push({ a: a.name, b: b.name, capabilities: caps, paths });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const slugify = (s) =>
|
|
110
|
+
s
|
|
111
|
+
.toLowerCase()
|
|
112
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
113
|
+
.replace(/^-+|-+$/g, '')
|
|
114
|
+
.slice(0, 60);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { exists, listFiles, readJson, withLock, writeJson } from './fs.js';
|
|
5
|
+
import { runtimeDir } from './runtime-path.js';
|
|
6
|
+
|
|
7
|
+
// The short shared lock makes registration atomic with respect to landing.
|
|
8
|
+
// Checks may run concurrently; landing refuses while any check is in flight.
|
|
9
|
+
export function beginCheck(root) {
|
|
10
|
+
const runtime = runtimeDir(root);
|
|
11
|
+
const marker = path.join(runtime, 'running-checks', `${crypto.randomUUID()}.json`);
|
|
12
|
+
withLock(path.join(runtime, 'landing'), () => writeJson(marker, { pid: process.pid, startedOn: new Date().toISOString() }));
|
|
13
|
+
return () => fs.rmSync(marker, { force: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function activeChecks(root) {
|
|
17
|
+
const dir = path.join(runtimeDir(root), 'running-checks');
|
|
18
|
+
return listFiles(dir).filter((file) => {
|
|
19
|
+
const marker = path.join(dir, file);
|
|
20
|
+
if (!exists(marker)) return false;
|
|
21
|
+
const data = readJson(marker);
|
|
22
|
+
try { process.kill(data.pid, 0); return true; }
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (error.code !== 'ESRCH') return true;
|
|
25
|
+
fs.rmSync(marker, { force: true });
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import YAML from 'yaml';
|
|
2
|
+
import { readOr, write } from './fs.js';
|
|
3
|
+
|
|
4
|
+
export const CONFIG_VERSION = 4;
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_CONFIG = {
|
|
7
|
+
version: CONFIG_VERSION,
|
|
8
|
+
tools: ['agents'],
|
|
9
|
+
lang: 'en',
|
|
10
|
+
profile: 'lean',
|
|
11
|
+
default_tier: 'auto',
|
|
12
|
+
confirm: { quick: 'proceed', spec: 'proceed' },
|
|
13
|
+
land: 'fold',
|
|
14
|
+
check: [],
|
|
15
|
+
guide: false,
|
|
16
|
+
hooks: true,
|
|
17
|
+
budgets: { INTENT: 120, ROADMAP: 80, NOW: 60, GLOSSARY: 200, spec: 250, rule: 120, change: 200, handoff: 100, 'always-on': 300 },
|
|
18
|
+
context: '',
|
|
19
|
+
paths: { specs: '.keelson/specs' },
|
|
20
|
+
refs: { architecture: null, decisions: null, tasks: null, ci: null },
|
|
21
|
+
models: {},
|
|
22
|
+
effort: { review_min: 'standard', plan_min: 'deep', verify_min: 'deep', escalate_after: 2 },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function parseConfig(raw, configPath = 'config.yaml') {
|
|
26
|
+
let parsed = {};
|
|
27
|
+
if (raw.trim()) {
|
|
28
|
+
try {
|
|
29
|
+
parsed = YAML.parse(raw) ?? {};
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw new Error(`cannot parse ${configPath}: ${e.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return migrate(deepMerge(structuredClone(DEFAULT_CONFIG), parsed), parsed.version ?? 1);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function loadConfig(configPath) {
|
|
38
|
+
return parseConfig(readOr(configPath, ''), configPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Bring older config shapes up to the current version. Pure; never touches disk. */
|
|
42
|
+
export function migrate(cfg, fromVersion) {
|
|
43
|
+
const out = { ...cfg };
|
|
44
|
+
if (fromVersion < 2) {
|
|
45
|
+
out.paths = { ...DEFAULT_CONFIG.paths, ...(out.paths ?? {}) };
|
|
46
|
+
out.refs = { ...DEFAULT_CONFIG.refs, ...(out.refs ?? {}) };
|
|
47
|
+
}
|
|
48
|
+
if (fromVersion < 3) {
|
|
49
|
+
out.budgets = { ...DEFAULT_CONFIG.budgets, ...(out.budgets ?? {}) };
|
|
50
|
+
out.guide = Boolean(out.guide);
|
|
51
|
+
}
|
|
52
|
+
if (fromVersion < 4) {
|
|
53
|
+
out.hooks = out.hooks !== false;
|
|
54
|
+
}
|
|
55
|
+
out.version = CONFIG_VERSION;
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function saveConfig(configPath, cfg) {
|
|
60
|
+
write(configPath, renderConfig(cfg));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function renderConfig(cfg) {
|
|
64
|
+
const doc = new YAML.Document(cfg);
|
|
65
|
+
return [
|
|
66
|
+
'# Keelson project configuration. Every key is optional; defaults are shown.',
|
|
67
|
+
'# paths.specs: where behaviour contracts live. refs.*: existing project material, referenced, never copied.',
|
|
68
|
+
'# check: commands (strings, or {name, command, kind}) that prove the code works. guide: learning mode. hooks: persist host hook installation.',
|
|
69
|
+
'# budgets: soft line budgets per document type; durable truth hard-fails at 2x, while temporary change/handoff files only warn.',
|
|
70
|
+
'# Docs: https://github.com/Atingaii/keelson/blob/main/docs/configuration.md',
|
|
71
|
+
doc.toString(),
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function deepMerge(base, extra) {
|
|
76
|
+
if (Array.isArray(base) || Array.isArray(extra)) return extra ?? base;
|
|
77
|
+
if (typeof base !== 'object' || base === null) return extra ?? base;
|
|
78
|
+
const out = { ...base };
|
|
79
|
+
for (const [k, v] of Object.entries(extra ?? {})) {
|
|
80
|
+
out[k] = typeof v === 'object' && v !== null && !Array.isArray(v) && typeof base[k] === 'object' && base[k] !== null ? deepMerge(base[k], v) : v;
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Normalise `check:` entries: a string, or {name, command, kind}. */
|
|
86
|
+
export function checkEntries(cfg) {
|
|
87
|
+
return (cfg.check ?? []).map((c, i) => {
|
|
88
|
+
if (typeof c === 'string') return { name: c, command: c, kind: guessKind(c) };
|
|
89
|
+
const command = c.command ?? c.cmd ?? '';
|
|
90
|
+
return { name: c.name ?? command ?? `check-${i + 1}`, command, kind: c.kind ?? guessKind(command) };
|
|
91
|
+
}).filter((c) => c.command);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function guessKind(cmd) {
|
|
95
|
+
const c = String(cmd).toLowerCase();
|
|
96
|
+
if (/lint|eslint|ruff|clippy|vet|fmt/.test(c)) return 'lint';
|
|
97
|
+
if (/tsc|typecheck|type-check|mypy|pyright/.test(c)) return 'typecheck';
|
|
98
|
+
if (/build|compile/.test(c)) return 'build';
|
|
99
|
+
if (/architecture|arch|depend|boundary|compat|contract/.test(c)) return 'fitness';
|
|
100
|
+
if (/test|spec|pytest|jest|vitest|mocha/.test(c)) return 'test';
|
|
101
|
+
return 'check';
|
|
102
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { readJson, withLock, writeJson } from './fs.js';
|
|
3
|
+
|
|
4
|
+
export function readDecisions(dir) {
|
|
5
|
+
const data = readJson(path.join(dir, 'decisions.json'), { schema: 1, decisions: [] });
|
|
6
|
+
validateDecisionData(data);
|
|
7
|
+
return data;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function validateDecisionData(data) {
|
|
11
|
+
if (!data || data.schema !== 1 || !Array.isArray(data.decisions)) throw new Error('unsupported decisions schema; expected schema 1 and decisions array');
|
|
12
|
+
const ids = new Set();
|
|
13
|
+
for (const d of data.decisions) {
|
|
14
|
+
if (!d || typeof d.id !== 'string' || !/^[\p{L}\p{N}_-]+$/u.test(d.id) || ids.has(d.id)) throw new Error('decision IDs must be unique words');
|
|
15
|
+
ids.add(d.id);
|
|
16
|
+
if (!['user', 'agent', 'reality'].includes(d.owner) || !['open', 'settled', 'assumed', 'rejected'].includes(d.state) || typeof d.question !== 'string' || !d.question.trim() || !Array.isArray(d.depends)) throw new Error(`invalid decision ${d.id}`);
|
|
17
|
+
if (!Array.isArray(d.history)) throw new Error(`${d.id}: history must be an array`);
|
|
18
|
+
if (['settled', 'assumed'].includes(d.state) && (typeof d.answer !== 'string' || !d.answer.trim() || typeof d.basis !== 'string' || !d.basis.trim())) throw new Error(`${d.id}: answer and basis required`);
|
|
19
|
+
if (d.irreversible && d.state === 'assumed') throw new Error(`${d.id}: irreversible decision cannot be assumed`);
|
|
20
|
+
}
|
|
21
|
+
const visiting = new Set();
|
|
22
|
+
const visited = new Set();
|
|
23
|
+
function visit(d) {
|
|
24
|
+
if (visiting.has(d.id)) throw new Error(`decision dependency cycle at ${d.id}`);
|
|
25
|
+
if (visited.has(d.id)) return;
|
|
26
|
+
visiting.add(d.id);
|
|
27
|
+
for (const id of d.depends) {
|
|
28
|
+
const dependency = data.decisions.find((x) => x.id === id);
|
|
29
|
+
if (!dependency) throw new Error(`${d.id}: missing dependency ${id}`);
|
|
30
|
+
visit(dependency);
|
|
31
|
+
}
|
|
32
|
+
visiting.delete(d.id); visited.add(d.id);
|
|
33
|
+
}
|
|
34
|
+
data.decisions.forEach(visit);
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function decisionFrontier(data, limit = 3) {
|
|
39
|
+
const ready = data.decisions.filter((d) => d.state === 'open' && d.depends.every((id) => data.decisions.some((x) => x.id === id && x.state === 'settled')));
|
|
40
|
+
return {
|
|
41
|
+
questions: ready.filter((d) => d.owner === 'user').slice(0, limit),
|
|
42
|
+
investigate: ready.filter((d) => d.owner !== 'user'),
|
|
43
|
+
blocked: data.decisions.filter((d) => d.state === 'open' && !ready.includes(d)),
|
|
44
|
+
assumptions: data.decisions.filter((d) => d.state === 'assumed'),
|
|
45
|
+
complete: data.decisions.every((d) => ['settled', 'rejected'].includes(d.state)),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function updateDecisions(dir, mutate) {
|
|
50
|
+
const file = path.join(dir, 'decisions.json');
|
|
51
|
+
return withLock(file, () => {
|
|
52
|
+
const data = readDecisions(dir);
|
|
53
|
+
mutate(data);
|
|
54
|
+
// Validate before writing by sharing the validator without a temporary file.
|
|
55
|
+
validateDecisionData(data);
|
|
56
|
+
writeJson(file, data);
|
|
57
|
+
return data;
|
|
58
|
+
});
|
|
59
|
+
}
|