simt-dev 0.6.3 → 0.7.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/CHANGELOG.md +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/mcp/tools.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { loadRegistry } from '../graph/load.mjs';
|
|
23
23
|
import { provenanceDigest } from '../projection/project.mjs';
|
|
24
24
|
import { discover } from '../compare/discover.mjs';
|
|
25
|
-
import {
|
|
25
|
+
import { buildRows, resolveConcurrency, summarise } from '../compare/row.mjs';
|
|
26
26
|
import { GATES } from '../hooks/gates.mjs';
|
|
27
27
|
import { checkRepo } from '../commands/sync.mjs';
|
|
28
28
|
import { diagnose, serialisedFindings } from '../commands/doctor.mjs';
|
|
@@ -248,9 +248,10 @@ const output = {
|
|
|
248
248
|
workspace: { type: 'string' },
|
|
249
249
|
body: { type: ['string', 'null'] },
|
|
250
250
|
bodyFrom: { type: 'string' },
|
|
251
|
-
bodyText: { type: 'string' },
|
|
251
|
+
bodyText: { type: ['string', 'null'] },
|
|
252
|
+
reason: { type: ['string', 'null'], description: 'why there is no body, when there is none' },
|
|
252
253
|
},
|
|
253
|
-
required: ['name', 'bytes', 'bodyFrom', 'requires', 'variants'],
|
|
254
|
+
required: ['name', 'bytes', 'body', 'bodyFrom', 'bodyText', 'reason', 'requires', 'variants'],
|
|
254
255
|
},
|
|
255
256
|
skills_find: {
|
|
256
257
|
type: 'object',
|
|
@@ -381,6 +382,9 @@ const output = {
|
|
|
381
382
|
repos: { type: 'array', items: { type: 'object' } },
|
|
382
383
|
worktrees: { type: 'array', items: { type: 'object' } },
|
|
383
384
|
directories: { type: 'array', items: { type: 'string' } },
|
|
385
|
+
partial: { type: 'boolean' },
|
|
386
|
+
cursor: { type: ['string', 'null'] },
|
|
387
|
+
total: { type: 'number' },
|
|
384
388
|
},
|
|
385
389
|
required: ['root', 'shape', 'expectedSha', 'summary', 'repos'],
|
|
386
390
|
},
|
|
@@ -423,6 +427,7 @@ const output = {
|
|
|
423
427
|
cut: { type: 'array', items: { type: 'string' } },
|
|
424
428
|
sections: { type: 'object', additionalProperties: { type: 'array', items: { type: 'string' } } },
|
|
425
429
|
text: { type: 'string' },
|
|
430
|
+
note: { type: ['string', 'null'] },
|
|
426
431
|
},
|
|
427
432
|
required: ['root', 'kind', 'basis', 'task', 'scope', 'budget', 'bytes', 'before', 'floorHeld', 'cut', 'sections', 'text'],
|
|
428
433
|
},
|
|
@@ -525,7 +530,7 @@ export const TOOLS = [
|
|
|
525
530
|
outputSchema: output.doctor,
|
|
526
531
|
name: 'doctor',
|
|
527
532
|
description:
|
|
528
|
-
'Classify a repository: stack tags,
|
|
533
|
+
'Classify a repository: stack tags, the facts with the file that proved each, and manifest presence. Call this first when the repo is unknown. Read-only: `--fix` and `--diff` stay on the command.',
|
|
529
534
|
inputSchema: rootArg,
|
|
530
535
|
async run(args) {
|
|
531
536
|
const root = rootOf(args);
|
|
@@ -548,7 +553,13 @@ export const TOOLS = [
|
|
|
548
553
|
name: 'skills_index',
|
|
549
554
|
description:
|
|
550
555
|
'Full skill catalogue: every skill with summary, size, stack tags and deferred flag. The fallback when brief returns nothing useful.',
|
|
551
|
-
inputSchema:
|
|
556
|
+
inputSchema: {
|
|
557
|
+
type: 'object',
|
|
558
|
+
properties: {
|
|
559
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
560
|
+
},
|
|
561
|
+
required: [],
|
|
562
|
+
},
|
|
552
563
|
async run(args) {
|
|
553
564
|
const { registry: reg, verified, workspace } = await registry(args);
|
|
554
565
|
return {
|
|
@@ -568,7 +579,7 @@ export const TOOLS = [
|
|
|
568
579
|
outputSchema: output.skills_brief,
|
|
569
580
|
name: 'skills_brief',
|
|
570
581
|
description:
|
|
571
|
-
'THE entry point. Resolve the skill set for a task: selected with why, deferred, and not selected with
|
|
582
|
+
'THE entry point. Resolve the skill set for a task: selected with why, deferred, and not selected with the reason to override.',
|
|
572
583
|
inputSchema: {
|
|
573
584
|
type: 'object',
|
|
574
585
|
properties: {
|
|
@@ -578,6 +589,7 @@ export const TOOLS = [
|
|
|
578
589
|
budget: { type: 'number', description: 'context budget in bytes (default 20480)' },
|
|
579
590
|
skill: { type: 'string', description: 'force a skill in, comma-separated' },
|
|
580
591
|
all: { type: 'boolean', description: 'ignore the budget' },
|
|
592
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
581
593
|
},
|
|
582
594
|
required: [],
|
|
583
595
|
},
|
|
@@ -610,18 +622,25 @@ export const TOOLS = [
|
|
|
610
622
|
name: { type: 'string' },
|
|
611
623
|
root: { type: 'string' },
|
|
612
624
|
maxBytes: { type: 'number', description: 'hard ceiling on the returned text' },
|
|
625
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
613
626
|
},
|
|
614
627
|
required: ['name'],
|
|
615
628
|
},
|
|
616
629
|
async run(args) {
|
|
617
630
|
const root = rootOf(args);
|
|
618
631
|
const facts = await skillsShowFacts(root, args?.name, args ?? {});
|
|
632
|
+
// The schema promises `body: null` when there is no body, and this used to hand the
|
|
633
|
+
// prose that says so to the field a client tests for null: a caller checking `body`
|
|
634
|
+
// for null read a paragraph as a body (Q56). The prose moves to `reason`, which is
|
|
635
|
+
// where a model looks for what to do about it.
|
|
636
|
+
const missing = typeof facts.bodyText === 'string' && facts.bodyText.startsWith('(body unavailable');
|
|
619
637
|
return {
|
|
620
638
|
...facts.skill,
|
|
621
639
|
workspace: facts.workspace,
|
|
622
|
-
body: facts.body,
|
|
640
|
+
body: missing ? null : facts.body,
|
|
623
641
|
bodyFrom: facts.bodyFrom,
|
|
624
|
-
bodyText: facts.bodyText,
|
|
642
|
+
bodyText: missing ? null : facts.bodyText,
|
|
643
|
+
reason: missing ? facts.bodyText : null,
|
|
625
644
|
};
|
|
626
645
|
},
|
|
627
646
|
},
|
|
@@ -632,11 +651,16 @@ export const TOOLS = [
|
|
|
632
651
|
description: 'Raw search across capability, keyword and summary. Use when skills_brief has nothing and you suspect a skill exists.',
|
|
633
652
|
inputSchema: {
|
|
634
653
|
type: 'object',
|
|
635
|
-
properties: {
|
|
654
|
+
properties: {
|
|
655
|
+
query: { type: 'string' },
|
|
656
|
+
root: { type: 'string' },
|
|
657
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
658
|
+
},
|
|
636
659
|
required: ['query'],
|
|
637
660
|
},
|
|
638
661
|
async run(args) {
|
|
639
|
-
|
|
662
|
+
const root = rootOf(args);
|
|
663
|
+
return skillsFindFacts(root, typeof args?.query === 'string' ? args.query : '', args ?? {});
|
|
640
664
|
},
|
|
641
665
|
},
|
|
642
666
|
|
|
@@ -647,7 +671,12 @@ export const TOOLS = [
|
|
|
647
671
|
'The signal chain for one skill in one repository: its score, the reasons, whether the stack gate admits it, and what would select it.',
|
|
648
672
|
inputSchema: {
|
|
649
673
|
type: 'object',
|
|
650
|
-
properties: {
|
|
674
|
+
properties: {
|
|
675
|
+
name: { type: 'string' },
|
|
676
|
+
root: { type: 'string' },
|
|
677
|
+
task: { type: 'string' },
|
|
678
|
+
registry: { type: 'string', description: 'path to skills.graph.json; else --registry / SIMT_REGISTRY / default tiers' },
|
|
679
|
+
},
|
|
651
680
|
required: ['name'],
|
|
652
681
|
},
|
|
653
682
|
async run(args) {
|
|
@@ -710,23 +739,37 @@ export const TOOLS = [
|
|
|
710
739
|
outputSchema: output.compare,
|
|
711
740
|
name: 'compare',
|
|
712
741
|
description:
|
|
713
|
-
'The workspace matrix:
|
|
742
|
+
'The workspace matrix: adoption, region, SHA, hooks and gates per repo, plus problems. Pages with limit/cursor; honors progressToken and cancellation.',
|
|
714
743
|
inputSchema: {
|
|
715
744
|
type: 'object',
|
|
716
745
|
properties: {
|
|
717
746
|
root: { type: 'string', description: 'a workspace directory, or one repository' },
|
|
718
747
|
workspace: { type: 'string', description: 'workspace root when root is not given' },
|
|
748
|
+
limit: { type: 'number', description: 'page size; without it every row is built' },
|
|
749
|
+
cursor: { type: 'string', description: 'opaque page start from a previous partial answer' },
|
|
750
|
+
concurrency: { type: 'number', description: 'rows built at once (default 4)' },
|
|
719
751
|
},
|
|
720
752
|
required: [],
|
|
721
753
|
},
|
|
722
|
-
async run(args) {
|
|
754
|
+
async run(args, ctx = {}) {
|
|
723
755
|
const root = args?.root ?? args?.workspace ?? (await resolveWorkspace(args ?? {})).root ?? process.cwd();
|
|
724
756
|
const found = await discover(root);
|
|
725
757
|
if (found.error) throw Object.assign(new Error(`${root}: ${found.error}`), { remedy: 'pass root' });
|
|
726
758
|
const expectedSha = await provenanceDigest();
|
|
727
759
|
const context = { expectedSha, gates: new Set(Object.keys(GATES)), gateKinds: GATE_KINDS };
|
|
728
|
-
|
|
729
|
-
|
|
760
|
+
// A page names its start as an index into discovery order, which is
|
|
761
|
+
// stable between calls on an unchanged estate; garbage parses to 0
|
|
762
|
+
// rather than erroring, because a dashboard that refuses to run over a
|
|
763
|
+
// stale cursor is worse than one that restarts the listing.
|
|
764
|
+
const start = Math.max(0, parseInt(typeof args?.cursor === 'string' ? args.cursor : '0', 10) || 0);
|
|
765
|
+
const limit = typeof args?.limit === 'number' && args.limit >= 1 ? Math.floor(args.limit) : null;
|
|
766
|
+
const page = limit === null ? found.repos : found.repos.slice(start, start + limit);
|
|
767
|
+
const rows = await buildRows(page, context, {
|
|
768
|
+
concurrency: resolveConcurrency(args ?? {}),
|
|
769
|
+
signal: ctx?.signal ?? null,
|
|
770
|
+
onBatch: ctx?.onProgress ? (done, total) => ctx.onProgress(start + done, found.repos.length) : null,
|
|
771
|
+
});
|
|
772
|
+
const next = limit !== null && start + page.length < found.repos.length ? String(start + page.length) : null;
|
|
730
773
|
return {
|
|
731
774
|
root,
|
|
732
775
|
shape: found.shape,
|
|
@@ -735,6 +778,7 @@ export const TOOLS = [
|
|
|
735
778
|
repos: rows,
|
|
736
779
|
worktrees: found.worktrees.map((w) => ({ name: w.name, owner: w.ownerName })),
|
|
737
780
|
directories: found.directories,
|
|
781
|
+
...(limit === null ? {} : { partial: next !== null, cursor: next, total: found.repos.length }),
|
|
738
782
|
};
|
|
739
783
|
},
|
|
740
784
|
},
|
|
@@ -869,7 +913,37 @@ export const TOOLS = [
|
|
|
869
913
|
build: false,
|
|
870
914
|
});
|
|
871
915
|
if (result.error) throw Object.assign(new Error('the index could not be read'), { remedy: `run: ${CLI} index` });
|
|
872
|
-
|
|
916
|
+
// A repository with no index is not a failed call. Every surface points here first,
|
|
917
|
+
// so the one tool told to be the entry point must answer in a repository that has
|
|
918
|
+
// never been indexed - it falls back to the skill packet, which is read from the
|
|
919
|
+
// package rather than the tree, and names the command that unlocks the rest (Q56).
|
|
920
|
+
if (result.cold) {
|
|
921
|
+
const task = typeof args?.task === 'string' ? args.task : '';
|
|
922
|
+
const facts = await skillsBriefFacts(root, { ...(args ?? {}), task });
|
|
923
|
+
// Through JSON once, so the packet carries exactly what the text block shows: a
|
|
924
|
+
// property whose value is `undefined` survives `stringify` as nothing at all, and
|
|
925
|
+
// the two blocks would then describe different objects.
|
|
926
|
+
const text = JSON.stringify(facts, null, 2);
|
|
927
|
+
const packet = JSON.parse(text);
|
|
928
|
+
const size = Buffer.byteLength(text);
|
|
929
|
+
return {
|
|
930
|
+
root,
|
|
931
|
+
kind: 'skills',
|
|
932
|
+
basis: 'registry',
|
|
933
|
+
task,
|
|
934
|
+
scope: [],
|
|
935
|
+
budget,
|
|
936
|
+
bytes: size,
|
|
937
|
+
before: size,
|
|
938
|
+
// The skill packet is one document and is not cut to fit: saying the floor held
|
|
939
|
+
// when the answer is over it is the class of claim this review exists to remove.
|
|
940
|
+
floorHeld: size <= budget,
|
|
941
|
+
cut: size <= budget ? [] : ['text'],
|
|
942
|
+
sections: { skills: (packet.resolved ?? []).map((s) => s.name) },
|
|
943
|
+
text,
|
|
944
|
+
note: `no index here, so this is the skill packet; run: ${CLI} index for the repository packet`,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
873
947
|
return {
|
|
874
948
|
root: result.root,
|
|
875
949
|
kind: result.kind,
|
package/src/measure/session.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { readFile } from 'node:fs/promises';
|
|
9
9
|
import { resolve } from 'node:path';
|
|
10
10
|
|
|
11
|
+
import { CLI } from '../name.mjs';
|
|
11
12
|
import { tokens } from './context.mjs';
|
|
12
13
|
|
|
13
14
|
const RULE_PATH_RE =
|
|
@@ -53,14 +54,14 @@ export async function measureSession(transcriptPath) {
|
|
|
53
54
|
return {
|
|
54
55
|
ok: false,
|
|
55
56
|
error: '--session requires a local transcript path',
|
|
56
|
-
remedy:
|
|
57
|
+
remedy: `pass a local JSONL or JSON transcript path: ${CLI} tokens --session <path>`,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
59
60
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(transcriptPath.trim())) {
|
|
60
61
|
return {
|
|
61
62
|
ok: false,
|
|
62
63
|
error: `${transcriptPath}: remote URLs are refused; session transcripts are read locally only (ADR-075)`,
|
|
63
|
-
remedy:
|
|
64
|
+
remedy: `pass a local file path on this machine: ${CLI} tokens --session ./session.jsonl`,
|
|
64
65
|
};
|
|
65
66
|
}
|
|
66
67
|
|
package/src/memory/decide.mjs
CHANGED
|
@@ -7,7 +7,8 @@ import { execFile } from 'node:child_process';
|
|
|
7
7
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { promisify } from 'node:util';
|
|
10
|
-
import {
|
|
10
|
+
import { recordCreatedFile } from '../journal.mjs';
|
|
11
|
+
import { decisionsDir } from '../decisions/dir.mjs';
|
|
11
12
|
|
|
12
13
|
const exec = promisify(execFile);
|
|
13
14
|
|
|
@@ -336,15 +337,19 @@ function renderDecisionScaffold({
|
|
|
336
337
|
].join('\n');
|
|
337
338
|
}
|
|
338
339
|
|
|
339
|
-
/**
|
|
340
|
+
/**
|
|
341
|
+
* Scaffold a new ADR in the repository's decisions directory with structured frontmatter.
|
|
342
|
+
*
|
|
343
|
+
* The number is claimed with `O_EXCL`: two concurrent scaffolds that both read
|
|
344
|
+
* the same maximum must not land on the same number, and a check-then-write
|
|
345
|
+
* would let the second silently overwrite the first. On collision the number is
|
|
346
|
+
* re-read and the next free one taken, so the retry always terminates.
|
|
347
|
+
*/
|
|
340
348
|
export async function scaffoldDecision(root, options = {}) {
|
|
341
|
-
const
|
|
342
|
-
const
|
|
349
|
+
const relDir = options.decisionsDir ?? (await decisionsDir(root));
|
|
350
|
+
const absDir = join(root, relDir);
|
|
343
351
|
const title = String(options.title ?? 'Untitled decision').trim() || 'Untitled decision';
|
|
344
352
|
const slug = slugifyTitle(title);
|
|
345
|
-
const file = `${id}-${slug}.md`;
|
|
346
|
-
const relPath = ['docs', 'decisions', file].join('/');
|
|
347
|
-
const fullPath = join(decisionsDir, file);
|
|
348
353
|
const affects = Array.isArray(options.affects)
|
|
349
354
|
? options.affects.map((p) => String(p).trim()).filter(Boolean)
|
|
350
355
|
: await diffAffectedPaths(root);
|
|
@@ -352,38 +357,58 @@ export async function scaffoldDecision(root, options = {}) {
|
|
|
352
357
|
const { entries: supersedes, errors } = parseSupersedesSpec(options.supersedes);
|
|
353
358
|
if (errors.length > 0) return { ok: false, errors };
|
|
354
359
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
360
|
+
// A dry run names the number without claiming it: the file must not exist
|
|
361
|
+
// afterwards, so no exclusive create happens here.
|
|
362
|
+
if (!options.write) {
|
|
363
|
+
const { number, id } = await nextDecisionNumber(absDir);
|
|
364
|
+
const file = `${id}-${slug}.md`;
|
|
365
|
+
return {
|
|
366
|
+
ok: true,
|
|
367
|
+
id,
|
|
368
|
+
number,
|
|
369
|
+
title,
|
|
370
|
+
slug,
|
|
371
|
+
file,
|
|
372
|
+
relPath: [relDir, file].join('/'),
|
|
373
|
+
path: join(absDir, file),
|
|
374
|
+
affects,
|
|
375
|
+
supersedes,
|
|
376
|
+
text: renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes }),
|
|
377
|
+
written: false,
|
|
378
|
+
};
|
|
371
379
|
}
|
|
372
380
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
id
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
381
|
+
await mkdir(absDir, { recursive: true });
|
|
382
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
383
|
+
const { number, id } = await nextDecisionNumber(absDir);
|
|
384
|
+
const file = `${id}-${slug}.md`;
|
|
385
|
+
const fullPath = join(absDir, file);
|
|
386
|
+
const text = renderDecisionScaffold({ id, title, date: options.date, status: options.status, affects, supersedes });
|
|
387
|
+
try {
|
|
388
|
+
await writeFile(fullPath, text, { flag: 'wx' });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error.code !== 'EEXIST') throw error;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
// The exclusive create is the journal entry's `before: null`: the file did
|
|
394
|
+
// not exist a moment ago, by construction rather than by re-reading.
|
|
395
|
+
if (options.journal) recordCreatedFile(root, options.journal, fullPath, text);
|
|
396
|
+
return {
|
|
397
|
+
ok: true,
|
|
398
|
+
id,
|
|
399
|
+
number,
|
|
400
|
+
title,
|
|
401
|
+
slug,
|
|
402
|
+
file,
|
|
403
|
+
relPath: [relDir, file].join('/'),
|
|
404
|
+
path: fullPath,
|
|
405
|
+
affects,
|
|
406
|
+
supersedes,
|
|
407
|
+
text,
|
|
408
|
+
written: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return { ok: false, errors: ['could not claim a free ADR number after 100 attempts'] };
|
|
387
412
|
}
|
|
388
413
|
|
|
389
414
|
/** Extract partial supersession claims stated in prose (`Status:` and header paragraphs). */
|
|
@@ -415,8 +440,15 @@ function prosePartialSupersessions(body) {
|
|
|
415
440
|
return claims;
|
|
416
441
|
}
|
|
417
442
|
|
|
418
|
-
/**
|
|
419
|
-
|
|
443
|
+
/**
|
|
444
|
+
* Lint a single ADR document for structured supersession conformance (plan 0003 R34).
|
|
445
|
+
*
|
|
446
|
+
* `historicalCutoff` exempts the first N records from the frontmatter requirement:
|
|
447
|
+
* they predate it, and demanding it retroactively would fail a history nobody can
|
|
448
|
+
* rewrite without rewriting history. Q55: the number lives in the repository's
|
|
449
|
+
* config (`decisions.historicalCutoff`, resolved by the caller), never in code.
|
|
450
|
+
*/
|
|
451
|
+
export function lintDecision(text, { file = 'ADR-000.md', historicalCutoff = 0 } = {}) {
|
|
420
452
|
const fileMatch = file !== 'ADR-000.md' ? /^(ADR-\d{3,})/i.exec(file) : null;
|
|
421
453
|
const headMatch = /^#\s+(ADR-\d{3,})\b/m.exec(String(text ?? ''));
|
|
422
454
|
const id = (fileMatch?.[1] ?? headMatch?.[1] ?? /^(ADR-\d{3,})/i.exec(file)?.[1] ?? '').toUpperCase() || null;
|
|
@@ -432,7 +464,7 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
|
|
|
432
464
|
remedy: 'fix simt-supersedes frontmatter to use [{ adr: "ADR-NNN", ruling: N }]',
|
|
433
465
|
}));
|
|
434
466
|
|
|
435
|
-
if (
|
|
467
|
+
if (!fm.hasFrontmatter && num !== null && num > 0 && num <= historicalCutoff) {
|
|
436
468
|
return { ok: findings.length === 0, findings };
|
|
437
469
|
}
|
|
438
470
|
|
|
@@ -455,13 +487,36 @@ export function lintDecision(text, { file = 'ADR-000.md', allowHistorical = fals
|
|
|
455
487
|
return { ok: findings.length === 0, findings };
|
|
456
488
|
}
|
|
457
489
|
|
|
458
|
-
/**
|
|
459
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Lint all ADR records in a decisions directory.
|
|
492
|
+
*
|
|
493
|
+
* Duplicate ids are a lint finding, not a silent keep-first: two files claiming
|
|
494
|
+
* one number means `byId` answers for only one of them, and the reader of the
|
|
495
|
+
* other gets a record the index cannot see. The kept record is the first in
|
|
496
|
+
* numeric filename order — the same one the loader keeps — so lint and load
|
|
497
|
+
* never name different winners.
|
|
498
|
+
*/
|
|
499
|
+
export async function lintDecisions(decisionsDir, { historicalCutoff = 0 } = {}) {
|
|
460
500
|
const names = (await readdir(decisionsDir).catch(() => [])).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n)).sort();
|
|
461
501
|
const findings = [];
|
|
502
|
+
const seen = new Map();
|
|
462
503
|
for (const file of names) {
|
|
504
|
+
const id = /^(ADR-\d{3,})/i.exec(file)?.[1]?.toUpperCase() ?? null;
|
|
505
|
+
if (id !== null) {
|
|
506
|
+
if (seen.has(id)) {
|
|
507
|
+
findings.push({
|
|
508
|
+
file,
|
|
509
|
+
id,
|
|
510
|
+
status: 'duplicate-decision-id',
|
|
511
|
+
detail: `decision id ${id} is claimed by ${seen.get(id)} and ${file}; the index only sees the first`,
|
|
512
|
+
remedy: 'renumber the newer record to a free number, then rerun the check',
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
seen.set(id, file);
|
|
517
|
+
}
|
|
463
518
|
const text = await readFile(join(decisionsDir, file), 'utf8');
|
|
464
|
-
findings.push(...lintDecision(text, { file,
|
|
519
|
+
findings.push(...lintDecision(text, { file, historicalCutoff }).findings);
|
|
465
520
|
}
|
|
466
521
|
return { ok: findings.length === 0, findings };
|
|
467
522
|
}
|