simt-dev 0.6.3 → 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.
Files changed (137) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +2 -2
  3. package/agents/simt-agent.md +84 -0
  4. package/docs/decisions/ADR-009-skill-layout.md +1 -1
  5. package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
  6. package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
  7. package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
  8. package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
  9. 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
  10. 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
  11. package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
  12. package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
  13. package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
  14. 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
  15. 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
  16. package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
  17. package/docs/decisions/README.md +9 -0
  18. package/package.json +4 -2
  19. package/rules/core.md +2 -2
  20. package/rules/mdc/test-gate.mdc +2 -0
  21. package/rules/stacks/android.md +3 -3
  22. package/rules/stacks/connectiq.md +2 -2
  23. package/rules/stacks/dart.md +1 -1
  24. package/rules/stacks/deno.md +1 -1
  25. package/rules/stacks/dotnet.md +1 -1
  26. package/rules/stacks/elixir.md +1 -1
  27. package/rules/stacks/go.md +1 -1
  28. package/rules/stacks/jvm.md +3 -2
  29. package/rules/stacks/node.md +2 -2
  30. package/rules/stacks/python.md +7 -6
  31. package/rules/stacks/ruby.md +1 -1
  32. package/rules/stacks/rust.md +1 -1
  33. package/rules/stacks/swift.md +1 -1
  34. package/rules/stacks/typescript.md +2 -2
  35. package/rules/stacks/uv.md +3 -2
  36. package/skills/simt-commands/SKILL.md +70 -0
  37. package/src/advice/advice.mjs +7 -3
  38. package/src/agenthooks/decide.mjs +141 -9
  39. package/src/agenthooks/opencode.mjs +72 -0
  40. package/src/agenthooks/prompt.mjs +6 -2
  41. package/src/agenthooks/render.mjs +186 -19
  42. package/src/agenthooks/shim.mjs +55 -0
  43. package/src/agents/render.mjs +277 -0
  44. package/src/argv.mjs +8 -0
  45. package/src/bench/ablate.mjs +31 -14
  46. package/src/bench/mine.mjs +48 -1
  47. package/src/bench/probes.mjs +5 -6
  48. package/src/bench/report.mjs +243 -80
  49. package/src/bench/run.mjs +12 -11
  50. package/src/bench/variants.mjs +112 -40
  51. package/src/brief/assemble.mjs +6 -0
  52. package/src/ci/workflow.mjs +16 -7
  53. package/src/commands/agent.mjs +206 -0
  54. package/src/commands/agenthook.mjs +109 -15
  55. package/src/commands/bench.mjs +2 -1
  56. package/src/commands/compare.mjs +8 -7
  57. package/src/commands/decide.mjs +6 -5
  58. package/src/commands/evidence.mjs +13 -3
  59. package/src/commands/gate.mjs +85 -19
  60. package/src/commands/graph.mjs +18 -5
  61. package/src/commands/help.mjs +35 -14
  62. package/src/commands/hooks.mjs +68 -25
  63. package/src/commands/impact.mjs +4 -2
  64. package/src/commands/index.mjs +17 -4
  65. package/src/commands/init.mjs +169 -37
  66. package/src/commands/lease.mjs +203 -35
  67. package/src/commands/lessons.mjs +65 -9
  68. package/src/commands/map.mjs +33 -18
  69. package/src/commands/plugin.mjs +1 -1
  70. package/src/commands/repoindex.mjs +1 -0
  71. package/src/commands/rules.mjs +92 -18
  72. package/src/commands/skills.mjs +60 -25
  73. package/src/commands/skillscompile.mjs +8 -7
  74. package/src/commands/skillsinstall.mjs +12 -0
  75. package/src/commands/slice.mjs +377 -18
  76. package/src/commands/sync.mjs +37 -22
  77. package/src/commands/tests.mjs +5 -2
  78. package/src/commands/undo.mjs +24 -16
  79. package/src/commands/upgrade.mjs +33 -1
  80. package/src/commands/why.mjs +10 -4
  81. package/src/compare/hooksstate.mjs +23 -1
  82. package/src/compare/learn.mjs +82 -57
  83. package/src/compare/parallel.mjs +50 -0
  84. package/src/compare/row.mjs +26 -1
  85. package/src/completions.mjs +5 -0
  86. package/src/decisions/dir.mjs +84 -0
  87. package/src/decisions/index.mjs +109 -11
  88. package/src/distill/distill.mjs +3 -1
  89. package/src/ecosystem/node-runner.mjs +144 -0
  90. package/src/explain.mjs +18 -6
  91. package/src/graph/load.mjs +38 -19
  92. package/src/hooks/capability.mjs +14 -1
  93. package/src/hooks/dispatcher.mjs +335 -13
  94. package/src/hooks/resolve.mjs +9 -3
  95. package/src/hooks/shim.mjs +12 -7
  96. package/src/identity.mjs +67 -14
  97. package/src/index/extract.mjs +192 -11
  98. package/src/index/persist.mjs +307 -47
  99. package/src/index/store.mjs +263 -60
  100. package/src/index/tests.mjs +6 -35
  101. package/src/journal.mjs +296 -77
  102. package/src/kernel/card.mjs +37 -12
  103. package/src/kernel/checks.mjs +44 -9
  104. package/src/kernel/variants.mjs +27 -16
  105. package/src/lease.mjs +97 -15
  106. package/src/ledger/index.mjs +91 -25
  107. package/src/mcp/resources.mjs +5 -3
  108. package/src/mcp/server.mjs +81 -22
  109. package/src/mcp/tools.mjs +45 -9
  110. package/src/memory/decide.mjs +98 -43
  111. package/src/memory/lessons.mjs +168 -33
  112. package/src/name.mjs +9 -0
  113. package/src/plan/index.mjs +31 -8
  114. package/src/plan/template.mjs +27 -5
  115. package/src/plugins.mjs +6 -2
  116. package/src/prbody.mjs +7 -5
  117. package/src/projection/baselines.mjs +1 -1
  118. package/src/projection/disclosure.mjs +2 -2
  119. package/src/projection/loaders.mjs +41 -1
  120. package/src/projection/lock.mjs +21 -14
  121. package/src/projection/manifest.mjs +69 -19
  122. package/src/projection/markers.mjs +43 -3
  123. package/src/projection/mdc.mjs +34 -7
  124. package/src/projection/project.mjs +162 -18
  125. package/src/projection/rules.mjs +54 -3
  126. package/src/projection/surfaces.mjs +9 -1
  127. package/src/repo-root.mjs +69 -0
  128. package/src/resolve/body.mjs +4 -0
  129. package/src/resolve/fingerprint.mjs +5 -0
  130. package/src/resolve/local.mjs +58 -0
  131. package/src/review/critic.mjs +4 -2
  132. package/src/secrets.mjs +165 -22
  133. package/src/state.mjs +76 -0
  134. package/src/status.mjs +9 -4
  135. package/src/toolwords.mjs +5 -0
  136. package/src/tsconfig.mjs +39 -99
  137. package/templates/plan/tracker.json +1 -1
@@ -14,6 +14,8 @@ import { readFile, stat } from 'node:fs/promises';
14
14
  import { relative, resolve, sep } from 'node:path';
15
15
  import { promisify } from 'node:util';
16
16
 
17
+ import { CLI } from '../name.mjs';
18
+
17
19
  const exec = promisify(execFile);
18
20
 
19
21
  export const CHECK_MARKER = 'simt-check';
@@ -48,11 +50,32 @@ export function checkDefect(body) {
48
50
  return null;
49
51
  }
50
52
 
53
+ /**
54
+ * What a runnable check enforces, in the reader's terms. The bare checker id is
55
+ * vocabulary for the manifest author; the projected region is read by a repository
56
+ * that has no manifest and no checker registry.
57
+ */
58
+ function checkSummary(check) {
59
+ const arg = check.arg;
60
+ switch (check.name) {
61
+ case 'max-file-lines':
62
+ return `by \`${CLI} check\`: more than ${DEFAULT_WARN} lines warns, more than ${DEFAULT_ERROR} fails (\`limits.fileLines\`)`;
63
+ case 'forbid-regex':
64
+ return `added lines matching \`${arg}\` are rejected`;
65
+ case 'forbidden-path-edit':
66
+ return `\`${arg}\` is not to be edited`;
67
+ case 'required-file':
68
+ return `\`${arg}\` must exist`;
69
+ default:
70
+ return check.name;
71
+ }
72
+ }
73
+
51
74
  /**
52
75
  * Replace each runnable check with one `Enforced:` line. A body with no marker
53
76
  * is returned unchanged, so a rule nobody marked does not move a byte.
54
77
  *
55
- * @returns {{body: string, enforced: {name: string}[], changed: boolean}}
78
+ * @returns {{body: string, enforced: {name: string, summary: string}[], changed: boolean}}
56
79
  */
57
80
  export function foldChecks(body) {
58
81
  if (checkDefect(body) !== null || !HAS_MARKER.test(body)) {
@@ -73,8 +96,9 @@ export function foldChecks(body) {
73
96
  }
74
97
  const indent = ITEM_START.exec(unit)?.[1] ?? '';
75
98
  const bullet = ITEM_START.test(unit) ? '- ' : '';
76
- kept.push(`${indent}${bullet}Enforced: ${parsed.name}`);
77
- enforced.push({ name: parsed.name });
99
+ const summary = checkSummary(parsed);
100
+ kept.push(`${indent}${bullet}Enforced: ${summary}`);
101
+ enforced.push({ name: parsed.name, summary });
78
102
  }
79
103
  const next = kept.join('\n');
80
104
  return { body: next, enforced, changed: next !== body };
@@ -130,15 +154,24 @@ async function runOne(root, check, manifest) {
130
154
  return forbiddenPath(check, diff);
131
155
  }
132
156
 
157
+ const TEXT_EXTENSIONS = new Set(['.md', '.txt', '.doc', '.docx', '.rst', '.adoc', '.tex']);
158
+
159
+ function isTextFile(rel) {
160
+ const segments = rel.split('/');
161
+ if (segments.includes('doc') || segments.includes('docs')) return true;
162
+ const dot = rel.lastIndexOf('.');
163
+ return dot !== -1 && TEXT_EXTENSIONS.has(rel.slice(dot).toLowerCase());
164
+ }
165
+
133
166
  async function fileLines(root, check, manifest) {
134
167
  const limits = manifest?.limits?.fileLines ?? {};
135
- const generated = limits.generated;
136
- if (generated !== undefined && !Array.isArray(generated)) {
168
+ const exclude = limits.exclude;
169
+ if (exclude !== undefined && !Array.isArray(exclude)) {
137
170
  return [{
138
171
  file: 'simt.json',
139
172
  status: 'invalid',
140
- detail: 'limits.fileLines.generated must be a list of paths',
141
- remedy: 'name each generated path, or remove the key',
173
+ detail: 'limits.fileLines.exclude must be a list of paths',
174
+ remedy: 'name each excluded path, or remove the key',
142
175
  }];
143
176
  }
144
177
  const warnAt = Number.isInteger(limits.warn) ? limits.warn : DEFAULT_WARN;
@@ -146,13 +179,15 @@ async function fileLines(root, check, manifest) {
146
179
  const files = await tracked(root);
147
180
  if (files === null) return [];
148
181
  const findings = [];
149
- const prefixes = Array.isArray(generated) ? generated.filter((g) => typeof g === 'string' && g !== '') : [];
182
+ const prefixes = Array.isArray(exclude) ? exclude.filter((g) => typeof g === 'string' && g !== '') : [];
183
+ const skipText = limits.excludeTextFiles === true;
150
184
  for (const rel of files) {
151
185
  // A committed dependency is not a file this repository wrote. Counting it
152
186
  // fails every install of this package, because the package's own sources
153
187
  // are longer than the ceiling the consumer declared.
154
188
  if (rel.split('/').includes('node_modules')) continue;
155
189
  if (exempt(rel, prefixes)) continue;
190
+ if (skipText && isTextFile(rel)) continue;
156
191
  let text;
157
192
  try {
158
193
  text = await readFile(resolve(root, rel));
@@ -170,7 +205,7 @@ async function fileLines(root, check, manifest) {
170
205
  file: rel,
171
206
  status: 'file-lines',
172
207
  detail: `${lines} lines, over the ceiling of ${errorAt}`,
173
- remedy: 'split the file, or name its generator in limits.fileLines.generated',
208
+ remedy: 'split the file, or name it in limits.fileLines.exclude',
174
209
  });
175
210
  } else if (lines > warnAt) {
176
211
  findings.push({
@@ -4,34 +4,45 @@
4
4
  // A rule line or list item may carry `<!-- simt-variants: <slot> <spec> -->`.
5
5
  // When unlocked, projection uses the baseline variant (`a`, or the line's own
6
6
  // prose) and strips the marker so `simt-` annotations never reach a reader.
7
- // When `simt-dev.lock.json` records a locked winner for `<ruleId>.<slot>`,
7
+ // When `simt.lock.json` records a locked winner for `<ruleId>.<slot>`,
8
8
  // projection deterministically renders that winning variant.
9
9
 
10
10
  import { readFile } from 'node:fs/promises';
11
11
  import { join } from 'node:path';
12
12
 
13
+ import { LOCK_FILE, LOCK_LEGACY } from '../name.mjs';
14
+
13
15
  export const VARIANTS_MARKER = 'simt-variants';
14
- const LOCK_FILE_NAME = 'simt-dev.lock.json';
15
16
 
16
17
  /**
17
- * Read the locked `variants` map from `simt-dev.lock.json` in `root`, or `{}` when absent.
18
+ * Read the locked `variants` map from `simt.lock.json` in `root`, or `{}` when absent.
19
+ * A lock under the legacy name is read until the next write migrates it (ADR-110); a
20
+ * lock that exists but does not parse is damage, and damage is not repaired by an
21
+ * older file.
18
22
  */
19
23
  export async function readLockedVariants(root) {
20
24
  if (!root) return {};
21
- try {
22
- const raw = await readFile(join(root, LOCK_FILE_NAME), 'utf8');
23
- const parsed = JSON.parse(raw);
24
- if (
25
- parsed &&
26
- typeof parsed === 'object' &&
27
- parsed.variants &&
28
- typeof parsed.variants === 'object' &&
29
- !Array.isArray(parsed.variants)
30
- ) {
31
- return parsed.variants;
25
+ for (const name of [LOCK_FILE, LOCK_LEGACY]) {
26
+ let raw;
27
+ try {
28
+ raw = await readFile(join(root, name), 'utf8');
29
+ } catch {
30
+ continue; // no lock under this name; try the next one
31
+ }
32
+ try {
33
+ const parsed = JSON.parse(raw);
34
+ if (
35
+ parsed &&
36
+ typeof parsed === 'object' &&
37
+ parsed.variants &&
38
+ typeof parsed.variants === 'object' &&
39
+ !Array.isArray(parsed.variants)
40
+ ) {
41
+ return parsed.variants;
42
+ }
43
+ } catch {
44
+ return {};
32
45
  }
33
- } catch {
34
- // Missing or unreadable lockfile falls back to baseline variants.
35
46
  }
36
47
  return {};
37
48
  }
package/src/lease.mjs CHANGED
@@ -14,13 +14,15 @@
14
14
  // path to the session that broke it. Which way that fails is the caller's decision —
15
15
  // the hook refuses edits, the command refuses to write (ADR-101 rulings 4).
16
16
 
17
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
17
+ import { existsSync, realpathSync } from 'node:fs';
18
+ import { mkdir, open, rename, stat, unlink, writeFile } from 'node:fs/promises';
18
19
  import { dirname, join, relative, resolve, sep } from 'node:path';
19
20
 
20
- import { STATE_DIR } from './name.mjs';
21
+ import { localRel, readStateFile } from './state.mjs';
21
22
 
22
23
  /** Where the claims live, in every repository: beside the index and the ledger. */
23
- export const LEASE_FILE = join(STATE_DIR, 'leases.json');
24
+ export const LEASE_FILE = localRel('leases.json');
25
+ const LEASE_FILE_LEGACY = '.simt/leases.json';
24
26
 
25
27
  /** The schema the file is written with; a reader that does not know it refuses. */
26
28
  const LEASE_SCHEMA = 1;
@@ -34,18 +36,46 @@ const LEASE_SCHEMA = 1;
34
36
  export const DEFAULT_TTL_SECONDS = 3600;
35
37
 
36
38
  /**
37
- * The repository-relative name of a path this tool may lease, or null when it is not
38
- * inside the repository at all.
39
+ * The one spelling a path has for lease matching: repository-relative, symlinks
40
+ * resolved, case-folded when the repository's git compares case-insensitively, or
41
+ * null when the path is not inside the repository at all.
39
42
  *
40
- * Relative to the root and slash-normalised, because that is the spelling the file
41
- * holds and the one a payload's absolute `file_path` has to become before the two can
42
- * be compared. The root itself leases nothing — there is no edit that "is" the
43
- * repository — so it answers null like a path that escapes it.
43
+ * A payload that reaches a file through a symlinked directory spells it one way,
44
+ * the claim recorded for the file spells it the other, and on a case-insensitive
45
+ * volume the two spellings that differ in case are the same file (review 0003
46
+ * Q16). Both sides of a comparison go through this, so a claim and an edit meet
47
+ * in the same spelling whatever road each one took to get there.
48
+ *
49
+ * The real path of the deepest existing ancestor is joined with the remainder,
50
+ * because `realpath` on a file that does not exist yet — the normal state of a
51
+ * write payload — would refuse. The root itself leases nothing — there is no
52
+ * edit that "is" the repository — so it answers null like a path that escapes it.
44
53
  */
45
- export function leasePathFor(root, input) {
46
- const rel = relative(resolve(root), resolve(root, String(input ?? '')));
54
+ export function canonicalLeasePath(root, input, { ignoreCase = false } = {}) {
55
+ const base = resolve(root, String(input ?? ''));
56
+ let anchor = base;
57
+ while (anchor !== resolve(anchor, '..')) {
58
+ if (existsSync(anchor)) break;
59
+ anchor = dirname(anchor);
60
+ }
61
+ let realAnchor;
62
+ try {
63
+ realAnchor = realpathSync(anchor);
64
+ } catch {
65
+ realAnchor = anchor;
66
+ }
67
+ const rest = relative(anchor, base);
68
+ const abs = rest === '' ? realAnchor : join(realAnchor, rest);
69
+ let realRoot;
70
+ try {
71
+ realRoot = realpathSync(resolve(root));
72
+ } catch {
73
+ realRoot = resolve(root);
74
+ }
75
+ const rel = relative(realRoot, abs);
47
76
  if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`)) return null;
48
- return rel.split(sep).join('/');
77
+ const slashed = rel.split(sep).join('/');
78
+ return ignoreCase ? slashed.toLowerCase() : slashed;
49
79
  }
50
80
 
51
81
  /**
@@ -60,13 +90,14 @@ export function leasePathFor(root, input) {
60
90
  */
61
91
  export async function readLeases(root) {
62
92
  const path = join(root, LEASE_FILE);
93
+ // The pre-move path is a fallback for one release (review 0003 Q19).
63
94
  let text;
64
95
  try {
65
- text = await readFile(path, 'utf8');
96
+ ({ text } = await readStateFile(root, LEASE_FILE, [LEASE_FILE_LEGACY]));
66
97
  } catch (error) {
67
- if (error.code === 'ENOENT') return { path, leases: [], error: null };
68
98
  return { path, leases: [], error: `${LEASE_FILE} could not be read (${error.message})` };
69
99
  }
100
+ if (text === null) return { path, leases: [], error: null };
70
101
  try {
71
102
  const parsed = JSON.parse(text);
72
103
  if (parsed === null || typeof parsed !== 'object' || !Array.isArray(parsed.leases)) {
@@ -108,6 +139,57 @@ export async function writeLeases(root, leases) {
108
139
  const sorted = [...leases].sort((a, b) => a.path.localeCompare(b.path));
109
140
  const body = `${JSON.stringify({ schemaVersion: LEASE_SCHEMA, leases: sorted }, null, 2)}\n`;
110
141
  await mkdir(dirname(path), { recursive: true });
111
- await writeFile(path, body, 'utf8');
142
+ // Atomic against a concurrent writer and a concurrent reader: a half-written
143
+ // claim file fails closed for every edit (review 0003 Q20).
144
+ const tmp = `${path}.tmp-${process.pid}`;
145
+ await writeFile(tmp, body, 'utf8');
146
+ await rename(tmp, path);
112
147
  return path;
113
148
  }
149
+
150
+ /** A lock is stale when its holder died holding it, not when it is merely slow. */
151
+ const LOCK_STALE_MS = 10_000;
152
+ const LOCK_WAIT_MS = 10_000;
153
+
154
+ /**
155
+ * Run `fn` with the claim file held: an `O_EXCL` lockfile (stale ones reaped),
156
+ * the read-modify-write re-done inside, the lock released afterwards.
157
+ *
158
+ * Twelve concurrent claims stored seven before this: every claimant read the
159
+ * same file, each wrote back only what it had seen. Re-reading under the lock
160
+ * closes that race (review 0003 Q20).
161
+ */
162
+ export async function withLeaseLock(root, fn) {
163
+ const lockPath = join(root, `${LEASE_FILE}.lock`);
164
+ await mkdir(dirname(lockPath), { recursive: true });
165
+ const deadline = Date.now() + LOCK_WAIT_MS;
166
+ for (;;) {
167
+ try {
168
+ const fd = await open(lockPath, 'wx');
169
+ await fd.close();
170
+ break;
171
+ } catch (error) {
172
+ if (error.code !== 'EEXIST') throw error;
173
+ let stale = false;
174
+ try {
175
+ const st = await stat(lockPath);
176
+ stale = Date.now() - st.mtimeMs > LOCK_STALE_MS;
177
+ } catch {
178
+ stale = true;
179
+ }
180
+ if (stale) {
181
+ await unlink(lockPath).catch(() => {});
182
+ continue;
183
+ }
184
+ if (Date.now() > deadline) {
185
+ throw new Error(`could not take the lease lock; remove ${lockPath} if no lease run is active`);
186
+ }
187
+ await new Promise((resolve) => setTimeout(resolve, 25));
188
+ }
189
+ }
190
+ try {
191
+ return await fn();
192
+ } finally {
193
+ await unlink(lockPath).catch(() => {});
194
+ }
195
+ }
@@ -7,26 +7,31 @@
7
7
 
8
8
  import { createHash } from 'node:crypto';
9
9
  import { execFile } from 'node:child_process';
10
- import { appendFile, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises';
10
+ import { appendFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
11
11
  import { tmpdir } from 'node:os';
12
12
  import { join } from 'node:path';
13
13
  import { promisify } from 'node:util';
14
14
 
15
15
  import {
16
- LESSONS_FILE,
17
16
  errorSignature,
18
17
  firstErrorLine,
19
18
  normaliseErrorLine,
20
19
  recordFailThenFix,
21
20
  } from '../memory/lessons.mjs';
21
+ import { TREE_EXCLUDES, localRel, readStateFile } from '../state.mjs';
22
22
 
23
23
  const exec = promisify(execFile);
24
24
 
25
- const LEDGER_FILE = '.simt/ledger.jsonl';
25
+ export const LEDGER_FILE = localRel('ledger.jsonl');
26
+ const LEDGER_FILE_LEGACY = '.simt/ledger.jsonl';
26
27
 
27
28
  /** Phrases that claim a run happened. `unverified` and `not verified` are removed
28
- * first, because both contain the word and neither is a claim. */
29
- const CLAIM = /\btests?\s+pass(?:ed)?\b|\bverified\b/i;
29
+ * first, because both contain the word and neither is a claim. Trailers
30
+ * (Signed-off-by and friends) are ignored, because they describe the commit,
31
+ * not the run (review 0003 Q18). */
32
+ const CLAIM =
33
+ /\btests?\s+pass(?:ed|ing)?\b|\btest\s+suite\s+(?:is\s+)?green\b|\bsuite\s+is\s+green\b|\bnpm\s+test\s+pass(?:es|ed)?\b|\bverified\b/i;
34
+ const TRAILER = /^(signed-off-by|co-authored-by|reviewed-by|acked-by|tested-by|fixes|closes)\s*:/i;
30
35
 
31
36
  /**
32
37
  * The tree of everything in the worktree, without staging any of it.
@@ -39,14 +44,12 @@ export async function treeId(root) {
39
44
  const index = join(dir, 'index');
40
45
  const env = { ...process.env, GIT_INDEX_FILE: index };
41
46
  try {
42
- // The ledger is a record of the tree, so it cannot be part of the tree it names.
43
- // `git add -A` would take it once the file exists, and the line would prove a
44
- // tree that stopped existing when the line was written.
45
- await exec(
46
- 'git',
47
- ['add', '-A', '--', '.', `:(exclude)${LEDGER_FILE}`, `:(exclude)${LESSONS_FILE}`],
48
- { cwd: root, env },
49
- );
47
+ // Machine-local state is a record of the tree, so it cannot be part of the
48
+ // tree it names. `git add -A` would take it once the file exists, and the
49
+ // line would prove a tree that stopped existing when the line was written
50
+ // (review 0003 Q19).
51
+ const excludes = TREE_EXCLUDES.map((p) => `:(exclude)${p}`);
52
+ await exec('git', ['add', '-A', '--', '.', ...excludes], { cwd: root, env });
50
53
  const { stdout } = await exec('git', ['write-tree'], { cwd: root, env });
51
54
  return stdout.trim();
52
55
  } finally {
@@ -58,12 +61,31 @@ export async function treeId(root) {
58
61
  * Append one run. The output itself is not stored: the digest is enough to notice a
59
62
  * change, and a ledger that kept logs would keep whatever the command printed.
60
63
  */
61
- export async function recordRun(root, { cmd, exit, ms, output }) {
64
+ /** The staged tree (`git write-tree` on the real index): what a commit-msg
65
+ * check must match, because the commit records the index, not the worktree.
66
+ * Null when git cannot answer; the caller then falls back to the worktree tree. */
67
+ export async function indexTreeId(root) {
68
+ try {
69
+ const { stdout } = await exec('git', ['write-tree'], { cwd: root });
70
+ const tree = String(stdout ?? '').trim();
71
+ return /^[0-9a-f]{40}$/.test(tree) ? tree : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export async function recordRun(root, { cmd, exit, ms, output, gate = null, kind = null }) {
62
78
  const { entries: prior } = await readLedger(root).catch(() => ({ entries: [] }));
79
+ const worktreeTree = await treeId(root);
80
+ const indexTree = await indexTreeId(root);
63
81
  const line = {
64
- v: 1,
82
+ v: 2,
65
83
  ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
66
- tree: await treeId(root),
84
+ tree: worktreeTree,
85
+ worktreeTree,
86
+ ...(indexTree !== null ? { indexTree } : {}),
87
+ ...(typeof gate === 'string' ? { gate } : {}),
88
+ ...(typeof kind === 'string' ? { kind } : {}),
67
89
  cmd,
68
90
  exit,
69
91
  ms,
@@ -76,7 +98,7 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
76
98
  }
77
99
  : {}),
78
100
  };
79
- await mkdir(join(root, '.simt'), { recursive: true });
101
+ await mkdir(join(root, '.simt', 'local'), { recursive: true });
80
102
  await appendFile(join(root, LEDGER_FILE), `${JSON.stringify(line)}\n`);
81
103
 
82
104
  if (exit === 0 && typeof cmd === 'string') {
@@ -100,13 +122,9 @@ export async function recordRun(root, { cmd, exit, ms, output }) {
100
122
  /** Every well-formed line. A line that does not parse is returned as an error so a
101
123
  * damaged ledger cannot look empty. */
102
124
  export async function readLedger(root) {
103
- let text;
104
- try {
105
- text = await readFile(join(root, LEDGER_FILE), 'utf8');
106
- } catch (error) {
107
- if (error.code === 'ENOENT') return { entries: [], errors: [] };
108
- throw error;
109
- }
125
+ // The pre-move path is a fallback for one release (review 0003 Q19).
126
+ const { text } = await readStateFile(root, LEDGER_FILE, [LEDGER_FILE_LEGACY]);
127
+ if (text === null) return { entries: [], errors: [] };
110
128
  const entries = [];
111
129
  const errors = [];
112
130
  for (const [i, raw] of text.split('\n').entries()) {
@@ -125,8 +143,56 @@ export async function readLedger(root) {
125
143
  return { entries, errors };
126
144
  }
127
145
 
146
+ /** Whether the entry counts as a test-kind run (review 0003 Q18). v1 lines
147
+ * carry no kind, so a command naming test counts; v2 lines use the recorded kind. */
148
+ function isTestKind(entry) {
149
+ if (typeof entry?.kind === 'string') return entry.kind === 'test';
150
+ return typeof entry?.cmd === 'string' && /\btest\b/i.test(entry.cmd);
151
+ }
152
+
153
+ /**
154
+ * The surviving passing entry for one gate on one staged tree, or null.
155
+ *
156
+ * A later failure for the same gate on the same tree cancels the pass, the
157
+ * way `hasTestEvidence` cancels a test pass — a gate that passed, then
158
+ * failed, proves nothing about the tree. Entries without an `indexTree`
159
+ * predate the staged-tree record and never match: a pass on an unknown tree
160
+ * is not evidence for this one (review 0003 Q23).
161
+ */
162
+ export function passEntryForGate(entries, gate, tree) {
163
+ const scoped = (entries ?? []).filter((e) => e?.gate === gate && e?.indexTree === tree);
164
+ for (let i = 0; i < scoped.length; i += 1) {
165
+ if (scoped[i].exit !== 0) continue;
166
+ const laterFailure = scoped.slice(i + 1).some((later) => later.exit !== 0);
167
+ if (!laterFailure) return scoped[i];
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** A passing test-kind entry for `tree` with no later failure of the same
173
+ * command on the same tree. v1 lines (no `kind`) keep today's reading: any
174
+ * passing entry for the tree counts, so old ledgers do not go red. */
175
+ export function hasTestEvidence(entries, tree) {
176
+ const scoped = entries.filter((e) => e.tree === tree || e.worktreeTree === tree || e.indexTree === tree);
177
+ for (let i = 0; i < scoped.length; i += 1) {
178
+ const candidate = scoped[i];
179
+ if (candidate.exit !== 0) continue;
180
+ const legacy = candidate.v !== 2 || candidate.kind === undefined || candidate.kind === null;
181
+ if (!legacy && !isTestKind(candidate)) continue;
182
+ const laterFailure = scoped
183
+ .slice(i + 1)
184
+ .some((later) => later.cmd === candidate.cmd && later.exit !== 0);
185
+ if (!laterFailure) return true;
186
+ }
187
+ return false;
188
+ }
189
+
128
190
  export function claimsVerification(message) {
129
- const text = String(message).replace(/\bunverified\b/gi, ' ').replace(/\bnot\s+verified\b/gi, ' ');
191
+ const lines = String(message).split('\n').filter((line) => !TRAILER.test(line.trim()));
192
+ const text = lines
193
+ .join('\n')
194
+ .replace(/\bunverified\b/gi, ' ')
195
+ .replace(/\bnot\s+verified\b/gi, ' ');
130
196
  return CLAIM.test(text);
131
197
  }
132
198
 
@@ -16,7 +16,7 @@
16
16
  import { readFile } from 'node:fs/promises';
17
17
  import { join } from 'node:path';
18
18
 
19
- import { DECISIONS_DIR, loadDecisions, redactEstate, redactInternalPaths } from '../decisions/index.mjs';
19
+ import { DECISIONS_DIR, loadDecisionsCached, redactEstate, redactInternalPaths } from '../decisions/index.mjs';
20
20
  import { planNext } from '../plan/index.mjs';
21
21
  import { loadMdcRules } from '../projection/mdc.mjs';
22
22
  import { desiredRegion, loadRules } from '../projection/rules.mjs';
@@ -122,7 +122,9 @@ export async function listResources(root = process.cwd()) {
122
122
  const [rules, mdc, { decisions }, region, plan] = await Promise.all([
123
123
  loadRules(null),
124
124
  loadMdcRules(),
125
- loadDecisions({ all: true }),
125
+ // Q55: the shipped records through the shared cache (root locates the cache
126
+ // file; the directory stays the package's own, which is what is served).
127
+ loadDecisionsCached({ root, dir: DECISIONS_DIR, all: true }),
126
128
  kernelRegion(root),
127
129
  planNext(root, null),
128
130
  ]);
@@ -211,7 +213,7 @@ export async function readResource(uri, root = process.cwd()) {
211
213
  // `loadDecisions` reads the index, not the records: it reports status, currency and
212
214
  // byte count, and keeps no body. Reading the file the index named is one read of a
213
215
  // record the reader has already asked for by id.
214
- const { decisions } = await loadDecisions({ all: true });
216
+ const { decisions } = await loadDecisionsCached({ root, dir: DECISIONS_DIR, all: true });
215
217
  const decision = decisions.find((candidate) => candidate.id === parsed.id);
216
218
  if (decision === undefined) return null;
217
219
  const raw = await readFile(join(DECISIONS_DIR, decision.file), 'utf8');
@@ -103,12 +103,17 @@ const LIST_CACHE = { ttlMs: 3600000, cacheScope: 'private' };
103
103
  * caller names the root and the default is the process's cwd, which is what an editor's
104
104
  * stdio server sits in.
105
105
  *
106
+ * `signal` aborts the one in-flight `tools/call` (wired by `serve` to
107
+ * `notifications/cancelled`); `notify` emits `notifications/progress` while it
108
+ * runs. Both are null in tests that call this directly, and every tool must
109
+ * treat a missing context as "run to completion quietly".
110
+ *
106
111
  * @param {object|null} request a parsed JSON-RPC message
107
112
  * @param {object[]} tools
108
- * @param {{packageVersion?: string, state?: {version: string|null}, root?: string}} options
113
+ * @param {{packageVersion?: string, state?: {version: string|null}, root?: string, signal?: AbortSignal|null, notify?: ((message: object) => void)|null}} options
109
114
  * @returns {Promise<object|null>}
110
115
  */
111
- export async function handle(request, tools, { packageVersion, state, root } = {}) {
116
+ export async function handle(request, tools, { packageVersion, state, root, signal = null, notify = null } = {}) {
112
117
  if (request === null || typeof request !== 'object' || Array.isArray(request)) {
113
118
  return error(null, RPC.REQUEST, 'request must be a JSON object');
114
119
  }
@@ -195,8 +200,24 @@ export async function handle(request, tools, { packageVersion, state, root } = {
195
200
  return error(id, RPC.METHOD, `no tool named "${name}"; call tools/list first`);
196
201
  }
197
202
  const args = params?.arguments ?? {};
203
+ // The one impurity in this file, and scoped to it: progress has to reach
204
+ // the client while the tool runs, not after it returns. Nothing else
205
+ // writes here - diagnostics still go to stderr, never this stream.
206
+ const progressToken = params?._meta?.progressToken;
207
+ const toolCtx = {
208
+ signal,
209
+ onProgress:
210
+ progressToken !== undefined && progressToken !== null && typeof notify === 'function'
211
+ ? (progress, total) =>
212
+ notify({
213
+ jsonrpc: '2.0',
214
+ method: 'notifications/progress',
215
+ params: { progressToken, progress, total },
216
+ })
217
+ : null,
218
+ };
198
219
  try {
199
- const payload = await tool.run(args);
220
+ const payload = await tool.run(args, toolCtx);
200
221
  // Both blocks, always. `structuredContent` is what a client validates against
201
222
  // `outputSchema` and what it can index; the text block is what a client that
202
223
  // predates structured output reads. Emitting one without the other leaves half
@@ -384,33 +405,71 @@ function error(id, code, message) {
384
405
  export async function serve(tools, { input = process.stdin, output = process.stdout, packageVersion, onError } = {}) {
385
406
  const rl = createInterface({ input, crlfDelay: Infinity });
386
407
  const state = { version: null };
387
- for await (const line of rl) {
388
- if (line.trim() === '') continue;
389
-
390
- // Parse and handling fail differently and must answer differently. A line that is
391
- // not JSON has no id to answer to, so it is a parse error with `id: null`; a
392
- // throw inside `handle` belongs to the request it was answering, so it keeps
393
- // that request's id — answering it as a parse error hangs the client that is
394
- // still waiting for its answer (Q12).
395
- let response;
408
+ // The in-flight call a `notifications/cancelled` can abort. Read this way
409
+ // because the loop below is blocked awaiting the tool while the client
410
+ // decides: the event loop still turns on subprocess waits, so the peek
411
+ // fires mid-run and the tool checks the signal between rows. The async
412
+ // iterator below yields the same line afterwards and `handle` answers it
413
+ // with null, as every notification is answered - the peek consumes nothing.
414
+ let inflight = null;
415
+ const peekCancel = (line) => {
416
+ if (!inflight || line.trim() === '') return;
396
417
  let message;
397
418
  try {
398
419
  message = JSON.parse(line);
399
- } catch (parseFailure) {
400
- response = error(null, RPC.PARSE, `invalid JSON: ${parseFailure.message}`);
420
+ } catch {
421
+ return;
422
+ }
423
+ if (message?.method === 'notifications/cancelled' && message?.params?.requestId === inflight.id) {
424
+ inflight.controller.abort();
401
425
  }
402
- if (response === undefined) {
426
+ };
427
+ rl.on('line', peekCancel);
428
+ try {
429
+ for await (const line of rl) {
430
+ if (line.trim() === '') continue;
431
+
432
+ // Parse and handling fail differently and must answer differently. A line that is
433
+ // not JSON has no id to answer to, so it is a parse error with `id: null`; a
434
+ // throw inside `handle` belongs to the request it was answering, so it keeps
435
+ // that request's id — answering it as a parse error hangs the client that is
436
+ // still waiting for its answer (Q12).
437
+ let response;
438
+ let message;
403
439
  try {
404
- response = await handle(message, tools, { packageVersion, state });
405
- } catch (handlerFailure) {
406
- const id = message !== null && typeof message === 'object' && 'id' in message ? message.id : null;
407
- response = error(id, RPC.INTERNAL, `internal error: ${handlerFailure?.message ?? handlerFailure}`);
440
+ message = JSON.parse(line);
441
+ } catch (parseFailure) {
442
+ response = error(null, RPC.PARSE, `invalid JSON: ${parseFailure.message}`);
443
+ }
444
+ if (response === undefined) {
445
+ // A notification never runs: without an id there is nothing to cancel
446
+ // and nothing to answer, so `notifications/cancelled` for an idle server
447
+ // is a no-op rather than an error.
448
+ const isNotification =
449
+ message === null || typeof message !== 'object' || Array.isArray(message) || message.id === undefined || message.id === null;
450
+ const controller = isNotification ? null : new AbortController();
451
+ if (controller) inflight = { id: message.id, controller };
452
+ try {
453
+ response = await handle(message, tools, {
454
+ packageVersion,
455
+ state,
456
+ signal: controller?.signal ?? null,
457
+ notify: (notification) => output.write(`${JSON.stringify(notification)}\n`),
458
+ });
459
+ } catch (handlerFailure) {
460
+ const id = message !== null && typeof message === 'object' && 'id' in message ? message.id : null;
461
+ response = error(id, RPC.INTERNAL, `internal error: ${handlerFailure?.message ?? handlerFailure}`);
462
+ } finally {
463
+ if (controller) inflight = null;
464
+ }
408
465
  }
466
+ if (response === null) continue;
467
+ output.write(`${JSON.stringify(response)}\n`);
409
468
  }
410
- if (response === null) continue;
411
- output.write(`${JSON.stringify(response)}\n`);
469
+ return EXIT_OK;
470
+ } finally {
471
+ rl.off('line', peekCancel);
412
472
  }
413
- return EXIT_OK;
414
473
  }
415
474
 
416
475
  const EXIT_OK = 0;