simt-dev 0.6.2 → 0.7.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/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.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-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- 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/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- 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 +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- 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 +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- 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 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- 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 +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +82 -21
- 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 +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Shell completions, generated from the command registry (plan 0003 R51).
|
|
2
|
+
//
|
|
3
|
+
// The inputs are the two tables that already exist and are already checked:
|
|
4
|
+
// `COMMANDS` — the same list the dispatcher routes and `--help` is asserted
|
|
5
|
+
// against (review 0002 S15) — and the flag tables in argv.mjs. A command added
|
|
6
|
+
// to `IMPLEMENTED` therefore completes in all three shells with no second
|
|
7
|
+
// edit, which is exactly the property S15 demanded of `--help`.
|
|
8
|
+
//
|
|
9
|
+
// Deterministic by construction: every list is sorted and deduplicated before
|
|
10
|
+
// it is rendered, no timestamp, no host name, no environment is read. The same
|
|
11
|
+
// registry produces byte-identical output on any machine, so the generated
|
|
12
|
+
// script can be committed, diffed and reviewed like any other file.
|
|
13
|
+
//
|
|
14
|
+
// Two boundaries, both deliberate:
|
|
15
|
+
//
|
|
16
|
+
// - Positional VALUES beyond subcommand words are file completion. The
|
|
17
|
+
// registry knows words, not the skill catalogue or a path's tree, and a
|
|
18
|
+
// completion that guesses is worse than one that falls back to files.
|
|
19
|
+
// - bash's script stays 3.2-compatible: macOS ships /bin/bash 3.2, and a
|
|
20
|
+
// completion script that fails to parse there is a silent no-op.
|
|
21
|
+
|
|
22
|
+
import { BOOL_FLAGS, VALUE_FLAGS } from './argv.mjs';
|
|
23
|
+
|
|
24
|
+
/** The shells this command can render, sorted. */
|
|
25
|
+
export const SHELLS = ['bash', 'fish', 'zsh'];
|
|
26
|
+
|
|
27
|
+
/** Every flag name, sorted: `--budget`, `--root`, ... Flags are global. */
|
|
28
|
+
const FLAGS = [...new Set([...BOOL_FLAGS, ...VALUE_FLAGS])].sort().map((f) => `--${f}`);
|
|
29
|
+
|
|
30
|
+
/** The value-flag case pattern (`--root|--task|...`), for the prev-word check. */
|
|
31
|
+
const VALUE_CASE = [...VALUE_FLAGS].sort().map((f) => `--${f}`).join('|');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The registry as completions need it: the first words, and for each group the
|
|
35
|
+
* subcommands under it. Derived from `commands` rather than hand-listed, so a
|
|
36
|
+
* new command or subcommand arrives here without an edit.
|
|
37
|
+
*
|
|
38
|
+
* @param {string[]} commands sorted dispatchable paths, e.g. `['brief', 'skills index']`
|
|
39
|
+
* @returns {{top: string[], groups: Array<[string, string[]]>}}
|
|
40
|
+
*/
|
|
41
|
+
export function shape(commands) {
|
|
42
|
+
const top = new Set();
|
|
43
|
+
const subs = new Map();
|
|
44
|
+
for (const path of commands) {
|
|
45
|
+
const [first, second] = path.split(' ');
|
|
46
|
+
top.add(first);
|
|
47
|
+
if (second !== undefined) {
|
|
48
|
+
if (!subs.has(first)) subs.set(first, new Set());
|
|
49
|
+
subs.get(first).add(second);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
top: [...top].sort(),
|
|
54
|
+
groups: [...subs.entries()]
|
|
55
|
+
.map(([group, set]) => [group, [...set].sort()])
|
|
56
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1)),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The completion script for one shell.
|
|
62
|
+
*
|
|
63
|
+
* @param {'bash'|'fish'|'zsh'} shell
|
|
64
|
+
* @param {string[]} commands the dispatchable command paths (`COMMANDS`)
|
|
65
|
+
* @returns {string} the script, newline-terminated
|
|
66
|
+
*/
|
|
67
|
+
export function renderCompletions(shell, commands) {
|
|
68
|
+
const s = shape(commands);
|
|
69
|
+
if (shell === 'bash') return renderBash(s);
|
|
70
|
+
if (shell === 'zsh') return renderZsh(s);
|
|
71
|
+
if (shell === 'fish') return renderFish(s);
|
|
72
|
+
throw new Error(`unknown shell \`${shell}\`; one of: ${SHELLS.join(', ')}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function renderBash({ top, groups }) {
|
|
76
|
+
const cases = groups
|
|
77
|
+
.map(([group, subs]) => ` ${group}) subs='${subs.join(' ')}' ;;`)
|
|
78
|
+
.join('\n');
|
|
79
|
+
return [
|
|
80
|
+
'# bash completion for simt. Generated by `simt completions bash`; do not edit.',
|
|
81
|
+
'# Install: source <(simt completions bash), or drop it in /etc/bash_completion.d/.',
|
|
82
|
+
'# bash 3.2 compatible: macOS ships /bin/bash 3.2.',
|
|
83
|
+
'_simt() {',
|
|
84
|
+
' local cur prev subs flags top',
|
|
85
|
+
' cur="${COMP_WORDS[COMP_CWORD]}"',
|
|
86
|
+
" prev=\"${COMP_WORDS[COMP_CWORD-1]}\"",
|
|
87
|
+
" subs=''",
|
|
88
|
+
'',
|
|
89
|
+
' # A flag that takes a value takes a word only the shell can name: paths.',
|
|
90
|
+
' case "$prev" in',
|
|
91
|
+
' --) return 0 ;;',
|
|
92
|
+
` ${VALUE_CASE}) return 0 ;;`,
|
|
93
|
+
' esac',
|
|
94
|
+
'',
|
|
95
|
+
` flags='${FLAGS.join(' ')}'`,
|
|
96
|
+
` top='${top.join(' ')}'`,
|
|
97
|
+
'',
|
|
98
|
+
' case "$cur" in',
|
|
99
|
+
' -*) COMPREPLY=( $(compgen -W "$flags" -- "$cur") ); return 0 ;;',
|
|
100
|
+
' esac',
|
|
101
|
+
' if [ "$COMP_CWORD" -eq 1 ]; then',
|
|
102
|
+
' COMPREPLY=( $(compgen -W "$top" -- "$cur") )',
|
|
103
|
+
' elif [ "$COMP_CWORD" -eq 2 ]; then',
|
|
104
|
+
' case "${COMP_WORDS[1]}" in',
|
|
105
|
+
cases,
|
|
106
|
+
' esac',
|
|
107
|
+
' if [ -n "$subs" ]; then',
|
|
108
|
+
' COMPREPLY=( $(compgen -W "$subs" -- "$cur") )',
|
|
109
|
+
' fi',
|
|
110
|
+
' fi',
|
|
111
|
+
'}',
|
|
112
|
+
'complete -o bashdefault -o default -F _simt simt',
|
|
113
|
+
'',
|
|
114
|
+
].join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderZsh({ top, groups }) {
|
|
118
|
+
const cases = groups
|
|
119
|
+
.map(([group, subs]) => ` ${group}) subs=( ${subs.join(' ')} ) ;;`)
|
|
120
|
+
.join('\n');
|
|
121
|
+
return [
|
|
122
|
+
'#compdef simt',
|
|
123
|
+
'# zsh completion for simt. Generated by `simt completions zsh`; do not edit.',
|
|
124
|
+
'# Install: save as `_simt` in a directory on $fpath and run `compinit`, or',
|
|
125
|
+
'# source this after compinit: `source <(simt completions zsh)`.',
|
|
126
|
+
'_simt() {',
|
|
127
|
+
' local cur prev',
|
|
128
|
+
// compsys sets `words` and `CURRENT` before the function runs; without the
|
|
129
|
+
// assignment `cur` and `prev` stay empty, so the flag and value-flag cases
|
|
130
|
+
// below never match and zsh completes only positionals (review 0003 Q51).
|
|
131
|
+
' cur=${words[CURRENT]}',
|
|
132
|
+
' prev=${words[CURRENT-1]}',
|
|
133
|
+
' local -a top flags subs',
|
|
134
|
+
` top=( ${top.join(' ')} )`,
|
|
135
|
+
` flags=( ${FLAGS.join(' ')} )`,
|
|
136
|
+
' subs=()',
|
|
137
|
+
'',
|
|
138
|
+
' # A flag that takes a value takes a word only the shell can name: paths.',
|
|
139
|
+
' case "$prev" in',
|
|
140
|
+
` --|${VALUE_CASE})`,
|
|
141
|
+
' _files',
|
|
142
|
+
' return',
|
|
143
|
+
' ;;',
|
|
144
|
+
' esac',
|
|
145
|
+
'',
|
|
146
|
+
' if [[ "$cur" == -* ]]; then',
|
|
147
|
+
' compadd -- ${flags}',
|
|
148
|
+
' return',
|
|
149
|
+
' fi',
|
|
150
|
+
'',
|
|
151
|
+
' case "${words[2]}" in',
|
|
152
|
+
cases,
|
|
153
|
+
' esac',
|
|
154
|
+
'',
|
|
155
|
+
' if (( CURRENT == 2 )); then',
|
|
156
|
+
' compadd -- ${top}',
|
|
157
|
+
' elif (( CURRENT == 3 )) && (( ${#subs} )); then',
|
|
158
|
+
' compadd -- ${subs}',
|
|
159
|
+
' else',
|
|
160
|
+
' _files',
|
|
161
|
+
' fi',
|
|
162
|
+
'}',
|
|
163
|
+
'',
|
|
164
|
+
'if (( $+functions[compdef] )); then',
|
|
165
|
+
' compdef _simt simt',
|
|
166
|
+
'fi',
|
|
167
|
+
'',
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderFish({ top, groups }) {
|
|
172
|
+
const level1 = top.map(
|
|
173
|
+
(word) => `complete -c simt -n '__fish_use_subcommand' -a '${word}'`,
|
|
174
|
+
);
|
|
175
|
+
const level2 = groups.flatMap(([group, subs]) =>
|
|
176
|
+
subs.map(
|
|
177
|
+
(word) => `complete -c simt -n '__fish_seen_subcommand_from ${group}' -a '${word}'`,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
const flagLines = FLAGS.map((flag) => {
|
|
181
|
+
const name = flag.slice(2);
|
|
182
|
+
return VALUE_FLAGS.has(name)
|
|
183
|
+
? `complete -c simt -l ${name} -r`
|
|
184
|
+
: `complete -c simt -l ${name}`;
|
|
185
|
+
});
|
|
186
|
+
return [
|
|
187
|
+
'# fish completion for simt. Generated by `simt completions fish`; do not edit.',
|
|
188
|
+
'# Install: simt completions fish > ~/.config/fish/completions/simt.fish',
|
|
189
|
+
...level1,
|
|
190
|
+
'',
|
|
191
|
+
...level2,
|
|
192
|
+
'',
|
|
193
|
+
...flagLines,
|
|
194
|
+
'',
|
|
195
|
+
].join('\n');
|
|
196
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The one answer to "where are this repository's decision records" (review 0003 Q55).
|
|
2
|
+
//
|
|
3
|
+
// Before this, every reader hard-coded `docs/decisions` while detection already
|
|
4
|
+
// knew four spellings (`docs/decisions`, `docs/adr`, `docs/adrs`, `adr`), so a
|
|
5
|
+
// repository that kept its records under `docs/adr/` was fingerprinted correctly
|
|
6
|
+
// and then unread by every command. One resolution, every reader.
|
|
7
|
+
//
|
|
8
|
+
// Precedence is declaration first. A manifest `decisions.dir` names the directory
|
|
9
|
+
// relative to the repository root; without one, the first existing candidate
|
|
10
|
+
// wins; without any, the default is `docs/decisions`. The cutoff below answers
|
|
11
|
+
// the companion question — which records predate structured frontmatter — and it
|
|
12
|
+
// lives in the same config (`decisions.historicalCutoff`), never in code.
|
|
13
|
+
|
|
14
|
+
import { stat } from 'node:fs/promises';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
import { readManifest } from '../projection/manifest.mjs';
|
|
18
|
+
|
|
19
|
+
/** Directory spellings a repository's decision records live under, in priority order. */
|
|
20
|
+
const DECISION_DIR_CANDIDATES = ['docs/decisions', 'docs/adr', 'docs/adrs', 'adr'];
|
|
21
|
+
|
|
22
|
+
/** The default when nothing is declared and nothing exists yet. */
|
|
23
|
+
const DEFAULT_DECISIONS_DIR = 'docs/decisions';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The repository's own `decisions` manifest key, or null.
|
|
27
|
+
*
|
|
28
|
+
* Read with the manifest loader (so the legacy name resolves the same way for
|
|
29
|
+
* this as for every other reader) and returned as-is: validation belongs to
|
|
30
|
+
* the caller that knows what it needs.
|
|
31
|
+
*/
|
|
32
|
+
async function manifestDecisions(root) {
|
|
33
|
+
try {
|
|
34
|
+
const { manifest } = await readManifest(root);
|
|
35
|
+
const decisions = manifest?.decisions;
|
|
36
|
+
return decisions !== null && typeof decisions === 'object' && !Array.isArray(decisions) ? decisions : null;
|
|
37
|
+
} catch {
|
|
38
|
+
// An unreadable manifest is not evidence either way; detection decides.
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function isDir(path) {
|
|
44
|
+
try {
|
|
45
|
+
return (await stat(path)).isDirectory();
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The relative decisions directory for this repository.
|
|
53
|
+
*
|
|
54
|
+
* @param {string} root repository root
|
|
55
|
+
* @returns {Promise<string>} e.g. `docs/decisions`
|
|
56
|
+
*/
|
|
57
|
+
export async function decisionsDir(root) {
|
|
58
|
+
const declared = await manifestDecisions(root);
|
|
59
|
+
if (typeof declared?.dir === 'string' && declared.dir.trim() !== '') {
|
|
60
|
+
return declared.dir.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
|
61
|
+
}
|
|
62
|
+
for (const candidate of DECISION_DIR_CANDIDATES) {
|
|
63
|
+
if (await isDir(join(root, candidate))) return candidate;
|
|
64
|
+
}
|
|
65
|
+
return DEFAULT_DECISIONS_DIR;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* How many leading records predate structured frontmatter and are exempt from it.
|
|
70
|
+
*
|
|
71
|
+
* The package's own history is the only corpus with such records, and even it
|
|
72
|
+
* declares the number in its own manifest rather than in code: a number in code
|
|
73
|
+
* is a second answer the next record silently moves. Any other repository gets
|
|
74
|
+
* zero unless it declares otherwise.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} root repository root
|
|
77
|
+
* @returns {Promise<number>}
|
|
78
|
+
*/
|
|
79
|
+
export async function decisionsCutoff(root) {
|
|
80
|
+
const declared = await manifestDecisions(root);
|
|
81
|
+
const value = declared?.historicalCutoff;
|
|
82
|
+
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return Math.floor(value);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
package/src/decisions/index.mjs
CHANGED
|
@@ -14,13 +14,20 @@
|
|
|
14
14
|
// such, because "no decision behind this" is exactly what a reviewer needs to
|
|
15
15
|
// see and exactly what an empty output would hide.
|
|
16
16
|
|
|
17
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
17
|
+
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
|
|
18
18
|
import { readdirSync } from 'node:fs';
|
|
19
19
|
import { dirname, join, resolve } from 'node:path';
|
|
20
20
|
import { fileURLToPath } from 'node:url';
|
|
21
21
|
import { MANIFEST_FILE } from '../name.mjs';
|
|
22
22
|
import { CLI } from '../name.mjs';
|
|
23
23
|
import { PREFIX_ARGUING, TOOL_WORDS } from '../toolwords.mjs';
|
|
24
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
25
|
+
import { packageVersion } from '../package-version.mjs';
|
|
26
|
+
// Q55: read lazily (see commandVerbs). The registry imports the `decide`
|
|
27
|
+
// command, which imports this module, so touching COMMANDS during module
|
|
28
|
+
// evaluation would read an initialising binding on one of the two load
|
|
29
|
+
// orders. Every use below happens at runtime, when both sides are loaded.
|
|
30
|
+
import { COMMANDS } from '../commands/index.mjs';
|
|
24
31
|
import { PARTIAL_CLAUSE, parseDecisionFrontmatter, splitFrontmatter } from '../memory/decide.mjs';
|
|
25
32
|
import { affectsTokens } from '../index/govern.mjs';
|
|
26
33
|
|
|
@@ -82,7 +89,16 @@ export function redactInternalPaths(text) {
|
|
|
82
89
|
return String(text ?? '').replace(internalPathPattern(), 'a file in this tool');
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
|
|
92
|
+
// Q55: four digits and more are real record numbers, not a typo. The old `\d{3}`
|
|
93
|
+
// silently dropped every citation past ADR-999, and lexical sorting put ADR-1000
|
|
94
|
+
// before ADR-999 in every listing that trusted string order.
|
|
95
|
+
const ADR_IN_TEXT = /\bADR-(\d{3,})\b/g;
|
|
96
|
+
|
|
97
|
+
/** Numeric sort for record ids: ADR-1000 follows ADR-999, not ADR-100. */
|
|
98
|
+
function compareAdrIds(a, b) {
|
|
99
|
+
const num = (id) => Number(id.replace(/^ADR-/i, ''));
|
|
100
|
+
return num(a) - num(b) || (a < b ? -1 : a > b ? 1 : 0);
|
|
101
|
+
}
|
|
86
102
|
|
|
87
103
|
/**
|
|
88
104
|
* A line that begins one of the record's other front-matter fields.
|
|
@@ -159,7 +175,7 @@ export function citations(text) {
|
|
|
159
175
|
for (const match of String(text ?? '').matchAll(ADR_IN_TEXT)) {
|
|
160
176
|
found.add(`ADR-${match[1]}`);
|
|
161
177
|
}
|
|
162
|
-
return [...found].sort();
|
|
178
|
+
return [...found].sort(compareAdrIds);
|
|
163
179
|
}
|
|
164
180
|
|
|
165
181
|
/**
|
|
@@ -252,7 +268,11 @@ export function parseDecision(file, text, { all = false } = {}) {
|
|
|
252
268
|
export async function loadDecisions({ all = false, dir = DECISIONS_DIR } = {}) {
|
|
253
269
|
let names;
|
|
254
270
|
try {
|
|
255
|
-
|
|
271
|
+
// Numeric, so the first record `byId` keeps under a duplicate is deterministic
|
|
272
|
+
// (lint flags the collision; the load does not guess between two files).
|
|
273
|
+
names = (await readdir(dir))
|
|
274
|
+
.filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n))
|
|
275
|
+
.sort((a, b) => compareAdrIds(adrIdOf(a) ?? a, adrIdOf(b) ?? b));
|
|
256
276
|
} catch (error) {
|
|
257
277
|
return { ok: false, decisions: [], errors: [`${dir}: unreadable (${error.message})`] };
|
|
258
278
|
}
|
|
@@ -300,11 +320,72 @@ export async function loadDecisions({ all = false, dir = DECISIONS_DIR } = {}) {
|
|
|
300
320
|
return { ok: errors.length === 0, decisions, errors };
|
|
301
321
|
}
|
|
302
322
|
|
|
323
|
+
/** The record id a filename claims, or null when it claims none. */
|
|
324
|
+
function adrIdOf(file) {
|
|
325
|
+
return /^ADR-(\d{3,})/i.exec(file)?.[0]?.toUpperCase() ?? null;
|
|
326
|
+
}
|
|
327
|
+
|
|
303
328
|
/** Index by id, for a lookup per cited ADR. */
|
|
304
329
|
export function byId(decisions) {
|
|
305
330
|
return new Map(decisions.map((d) => [d.id, d]));
|
|
306
331
|
}
|
|
307
332
|
|
|
333
|
+
/** The command verbs a record may quote, derived from the dispatch registry. */
|
|
334
|
+
export function decisionVerbs() {
|
|
335
|
+
return commandVerbs();
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** One file's cache identity: name, size and mtime. Any write changes one of them. */
|
|
339
|
+
async function fileSignature(dir, name) {
|
|
340
|
+
const st = await stat(join(dir, name));
|
|
341
|
+
return { name, size: st.size, mtimeMs: st.mtimeMs };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Load the records, reusing a machine-local cache when the tree is unchanged.
|
|
346
|
+
*
|
|
347
|
+
* Every call used to re-parse every record (`rules why` 44 ms; part of
|
|
348
|
+
* `resources/list` 771 ms). The cache lives under `.simt/local/` — machine
|
|
349
|
+
* state, never committed — and is keyed on the directory, the `all` flag, the
|
|
350
|
+
* running version and one signature per file, so an edit, an add or an upgrade
|
|
351
|
+
* all miss honestly. Best-effort throughout: any cache failure loads fresh.
|
|
352
|
+
*
|
|
353
|
+
* @param {{root: string, dir: string, all?: boolean}} input `dir` is absolute;
|
|
354
|
+
* callers resolve it with `decisionsDir(root)` (or the package directory for
|
|
355
|
+
* shipped records) so this loader never guesses whose records these are.
|
|
356
|
+
*/
|
|
357
|
+
export async function loadDecisionsCached({ root, dir, all = false } = {}) {
|
|
358
|
+
const cachePath = root ? join(root, LOCAL_DIR, 'decisions.index.json') : null;
|
|
359
|
+
let files = null;
|
|
360
|
+
try {
|
|
361
|
+
const names = (await readdir(dir)).filter((n) => /^ADR-\d{3,}-.*\.md$/i.test(n));
|
|
362
|
+
files = [];
|
|
363
|
+
for (const name of names) files.push(await fileSignature(dir, name));
|
|
364
|
+
files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
365
|
+
} catch {
|
|
366
|
+
return loadDecisions({ all, dir });
|
|
367
|
+
}
|
|
368
|
+
const key = { v: 1, tool: packageVersion(), dir, all, files };
|
|
369
|
+
if (cachePath) {
|
|
370
|
+
try {
|
|
371
|
+
const cached = JSON.parse(await readFile(cachePath, 'utf8'));
|
|
372
|
+
if (JSON.stringify(cached.key) === JSON.stringify(key)) return cached.result;
|
|
373
|
+
} catch {
|
|
374
|
+
// A missing or corrupt cache is a miss, never an error.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const fresh = await loadDecisions({ all, dir });
|
|
378
|
+
if (cachePath) {
|
|
379
|
+
try {
|
|
380
|
+
await mkdir(join(root, LOCAL_DIR), { recursive: true });
|
|
381
|
+
await writeFile(cachePath, JSON.stringify({ key, result: fresh }), 'utf8');
|
|
382
|
+
} catch {
|
|
383
|
+
// A read-only checkout keeps working uncached.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return fresh;
|
|
387
|
+
}
|
|
388
|
+
|
|
308
389
|
/**
|
|
309
390
|
* What the decision actually says, in the reader's terms.
|
|
310
391
|
*
|
|
@@ -506,7 +587,7 @@ const section = (name) => {
|
|
|
506
587
|
// A record whose subject is the prefix is arguing rather than citing, so it names none.
|
|
507
588
|
writtenAgainst: PREFIX_ARGUING.has(file)
|
|
508
589
|
? []
|
|
509
|
-
: [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name)))].sort(),
|
|
590
|
+
: [...new Set((text.match(ESTATE) ?? []).filter((name) => !TOOL_WORDS.has(name.toLowerCase())))].sort(),
|
|
510
591
|
};
|
|
511
592
|
}
|
|
512
593
|
|
|
@@ -545,8 +626,11 @@ const section = (name) => {
|
|
|
545
626
|
* them - which is what this file used to do. Detection of those now depends on a name
|
|
546
627
|
* never being written into shipped text, rather than on a check.
|
|
547
628
|
*/
|
|
548
|
-
|
|
549
|
-
|
|
629
|
+
// Q55: one shape, two flags. The estate-name pattern used to be written out twice
|
|
630
|
+
// (`ESTATE_NAME` and `ESTATE`), so a shape fixed in one kept matching in the other.
|
|
631
|
+
const ESTATE_SOURCE = '\\b(?:simt-(?!dev\\b)[\\w-]+|repo-\\d{2})\\b';
|
|
632
|
+
|
|
633
|
+
export const ESTATE_NAME = new RegExp(ESTATE_SOURCE);
|
|
550
634
|
|
|
551
635
|
/** Replace estate repository names with a phrase a reader who does not have them can use. */
|
|
552
636
|
export function redactEstate(text) {
|
|
@@ -554,7 +638,7 @@ export function redactEstate(text) {
|
|
|
554
638
|
// The tool's own names are left where they are. Redacting them turned a ruling about
|
|
555
639
|
// `simt-priority` into a sentence about `a repository`, which is how a reader learned
|
|
556
640
|
// that the record had surveyed something and not what it said (ADR-080).
|
|
557
|
-
TOOL_WORDS.has(name) ? name : 'a repository',
|
|
641
|
+
TOOL_WORDS.has(name.toLowerCase()) ? name : 'a repository',
|
|
558
642
|
);
|
|
559
643
|
}
|
|
560
644
|
|
|
@@ -575,10 +659,24 @@ export function redactEstate(text) {
|
|
|
575
659
|
* Only a real verb follows, which is what keeps `simt-dev.json`, `simt-dev-check.yml` and
|
|
576
660
|
* `simt-dev's` untouched - those are the project's artefacts and the project keeps its name.
|
|
577
661
|
*/
|
|
578
|
-
|
|
662
|
+
// Q55: the verbs a record may quote are the commands this binary dispatches,
|
|
663
|
+
// not a hand list. The list used to miss `pack`, `plugin` and `lease`, so a record
|
|
664
|
+
// quoting one read as prose instead of a runnable command.
|
|
665
|
+
let verbAlternation = null;
|
|
666
|
+
function commandVerbs() {
|
|
667
|
+
if (verbAlternation === null) {
|
|
668
|
+
const words = new Set();
|
|
669
|
+
for (const path of COMMANDS) {
|
|
670
|
+
const first = path.split(' ')[0];
|
|
671
|
+
if (first) words.add(first.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
672
|
+
}
|
|
673
|
+
verbAlternation = `(?:${[...words].sort().join('|')}|--[a-z-]+)`;
|
|
674
|
+
}
|
|
675
|
+
return verbAlternation;
|
|
676
|
+
}
|
|
579
677
|
|
|
580
678
|
function redactCommand(text) {
|
|
581
|
-
return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${
|
|
679
|
+
return String(text ?? '').replace(new RegExp(`\\bsimt-dev (?=${commandVerbs()})\\b`, 'g'), `${CLI} `);
|
|
582
680
|
}
|
|
583
681
|
|
|
584
682
|
/**
|
|
@@ -590,7 +688,7 @@ function redactCommand(text) {
|
|
|
590
688
|
* enumerating the names here put the portfolio in every adopting repository. The records
|
|
591
689
|
* themselves now carry `repo-NN` labels, so that is what this matches in practice.
|
|
592
690
|
*/
|
|
593
|
-
const ESTATE =
|
|
691
|
+
const ESTATE = new RegExp(ESTATE_SOURCE, 'gi');
|
|
594
692
|
|
|
595
693
|
/** Trim to whole sentences within a budget, never leaving a stub fragment. */
|
|
596
694
|
function sentence(text, limit = 260) {
|