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
@@ -13,20 +13,64 @@
13
13
 
14
14
  import { createHash } from 'node:crypto';
15
15
  import { execFile } from 'node:child_process';
16
- import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
16
+ import { mkdir, open, readdir, readFile, writeFile } from 'node:fs/promises';
17
17
  import { dirname, join } from 'node:path';
18
18
  import { promisify } from 'node:util';
19
19
 
20
20
  import { postDeliveryJson } from '../agenthooks/render.mjs';
21
21
  import { lessonPaths } from '../index/govern.mjs';
22
+ import { recordCreatedFile } from '../journal.mjs';
23
+ import { redactText } from '../secrets.mjs';
24
+ import { localRel, readStateFile } from '../state.mjs';
22
25
  import { splitFrontmatter } from './decide.mjs';
23
26
 
24
27
  const exec = promisify(execFile);
25
28
 
26
- export const LESSONS_FILE = '.simt/lessons.jsonl';
29
+ export const LESSONS_FILE = localRel('lessons.jsonl');
30
+ const LESSONS_FILE_LEGACY = '.simt/lessons.jsonl';
31
+ const LEDGER_REL = localRel('ledger.jsonl');
32
+ const LEDGER_REL_LEGACY = '.simt/ledger.jsonl';
27
33
  export const LESSONS_DIR = 'docs/lessons';
28
34
  export const LESSON_KEYS = new Set(['simt-trigger', 'simt-evidence', 'simt-expires']);
29
- const DEFAULT_EXPIRES = '2027-04-01';
35
+
36
+ /**
37
+ * A stored `fixDiff` is evidence, not an archive: the diff that proved the fix,
38
+ * capped so one generated-file revert cannot bloat every read of the ledger.
39
+ * Truncation keeps the head (the hunk headers that name the files) and says so.
40
+ */
41
+ const FIX_DIFF_BUDGET = 20000;
42
+
43
+ /**
44
+ * The default expiry is relative to the promotion, not a date in the source:
45
+ * a fixed date rots (every lesson written today expires on someone else's
46
+ * schedule) and a missing one lives forever because nothing reads the field.
47
+ * Half a year is long enough to recur and short enough to re-earn.
48
+ */
49
+ const EXPIRY_DAYS = 180;
50
+
51
+ export function defaultExpires(from = new Date()) {
52
+ const at = new Date(from);
53
+ at.setUTCDate(at.getUTCDate() + EXPIRY_DAYS);
54
+ return at.toISOString().slice(0, 10);
55
+ }
56
+
57
+ /**
58
+ * Whether a `simt-expires` value has passed. Unparseable values are NOT expired:
59
+ * a malformed date is a data error the reader should see, and hiding the lesson
60
+ * would turn it into a silent one (review 0003 Q36).
61
+ *
62
+ * @param {string|null|undefined} expires
63
+ * @param {Date} [now]
64
+ * @returns {boolean}
65
+ */
66
+ export function isExpired(expires, now = new Date()) {
67
+ if (expires === null || expires === undefined || String(expires).trim() === '') return false;
68
+ const at = new Date(String(expires).trim());
69
+ if (Number.isNaN(at.getTime())) return false;
70
+ const today = new Date(now);
71
+ today.setUTCHours(0, 0, 0, 0);
72
+ return at.getTime() < today.getTime();
73
+ }
30
74
 
31
75
  const STOP_WORDS = new Set([
32
76
  'the', 'and', 'for', 'with', 'that', 'this', 'from', 'into', 'when', 'were',
@@ -143,13 +187,14 @@ function slugify(text) {
143
187
  * @returns {Promise<{candidates: Array<object>, errors: string[]}>}
144
188
  */
145
189
  export async function readLessonCandidates(root) {
190
+ // The pre-move path is a fallback for one release (review 0003 Q19).
146
191
  let text;
147
192
  try {
148
- text = await readFile(join(root, LESSONS_FILE), 'utf8');
193
+ ({ text } = await readStateFile(root, LESSONS_FILE, [LESSONS_FILE_LEGACY]));
149
194
  } catch (error) {
150
- if (error.code === 'ENOENT') return { candidates: [], errors: [] };
151
195
  throw error;
152
196
  }
197
+ if (text === null) return { candidates: [], errors: [] };
153
198
  const candidates = [];
154
199
  const errors = [];
155
200
  for (const [i, raw] of text.split('\n').entries()) {
@@ -175,15 +220,69 @@ async function writeLessonCandidates(root, candidates) {
175
220
  await writeFile(full, body ? `${body}\n` : '', 'utf8');
176
221
  }
177
222
 
223
+ /**
224
+ * Append one record with `O_APPEND`: the ledger is written by concurrent
225
+ * processes (gates, hooks, the CLI), and a read-modify-write loses one of two
226
+ * simultaneous appends. The position is the kernel's, not a length read earlier.
227
+ *
228
+ * @param {string} root
229
+ * @param {object} record
230
+ */
231
+ async function appendCandidateLine(root, record) {
232
+ const full = join(root, LESSONS_FILE);
233
+ await mkdir(dirname(full), { recursive: true });
234
+ const handle = await open(full, 'a');
235
+ try {
236
+ await handle.appendFile(`${JSON.stringify(record)}\n`, 'utf8');
237
+ } finally {
238
+ await handle.close();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The candidate id is a content hash, not a counter. Ids were `c-${length+1}`:
244
+ * two processes appending concurrently computed the same next index, and a
245
+ * hand-edited or truncated file renumbered everything after it. The hash covers
246
+ * the fields that make a candidate distinct, so the same candidate always maps
247
+ * to the same id and two different ones never share it (review 0003 Q36).
248
+ *
249
+ * @param {{kind: string, signature: string, evidence: string[], cmd: string|null}} parts
250
+ * @returns {string}
251
+ */
252
+ export function lessonIdFor({ kind, signature, evidence = [], cmd = null }) {
253
+ const digest = createHash('sha256')
254
+ .update([kind, signature, ...evidence, cmd ?? ''].join('\n'), 'utf8')
255
+ .digest('hex');
256
+ return `l-${digest.slice(0, 12)}`;
257
+ }
258
+
259
+ /**
260
+ * Truncate an unbounded diff to the evidence budget, keeping the head: the hunk
261
+ * headers that name the files are the part a reader acts on.
262
+ *
263
+ * @param {string} diff
264
+ * @returns {string}
265
+ */
266
+ function capFixDiff(diff) {
267
+ const text = String(diff ?? '');
268
+ if (text.length <= FIX_DIFF_BUDGET) return text;
269
+ return `${text.slice(0, FIX_DIFF_BUDGET)}\n… (truncated to ${FIX_DIFF_BUDGET} chars)`;
270
+ }
271
+
178
272
  function sameEvidence(a = [], b = []) {
179
273
  if (a.length !== b.length) return false;
180
274
  return a.every((v, i) => v === b[i]);
181
275
  }
182
276
 
183
277
  /**
184
- * Append one deterministic candidate to `.simt/lessons.jsonl`, deduplicating by
278
+ * Append one deterministic candidate to the lessons ledger, deduplicating by
185
279
  * `(kind, signature, evidence)`.
186
280
  *
281
+ * The stored text is redacted before anything else: candidates keep the raw
282
+ * commands that failed, and a command can carry a token. Redaction runs before
283
+ * the signature is hashed so the same failure redacts to the same signature
284
+ * (review 0003 Q36).
285
+ *
187
286
  * @param {string} root
188
287
  * @param {object} candidate
189
288
  * @returns {Promise<object>}
@@ -191,15 +290,22 @@ function sameEvidence(a = [], b = []) {
191
290
  export async function appendLessonCandidate(root, candidate) {
192
291
  const { candidates } = await readLessonCandidates(root);
193
292
  const kind = String(candidate.kind ?? 'fail-then-fix');
194
- const errorLine = normaliseErrorLine(candidate.errorLine ?? firstErrorLine(candidate.output ?? candidate.summary ?? ''));
293
+ // Redact first: the signature must be a function of the stored bytes, not of
294
+ // a secret nobody can reproduce (and must never be stored).
295
+ const errorLine = redactText(
296
+ normaliseErrorLine(candidate.errorLine ?? firstErrorLine(candidate.output ?? candidate.summary ?? '')),
297
+ );
195
298
  const signature = candidate.signature ?? errorSignature(errorLine);
196
- const evidence = Array.isArray(candidate.evidence) ? candidate.evidence.map(String).filter(Boolean) : [];
299
+ const evidence = Array.isArray(candidate.evidence)
300
+ ? candidate.evidence.map((e) => redactText(String(e)).trim()).filter(Boolean)
301
+ : [];
302
+ const cmd = candidate.cmd === null || candidate.cmd === undefined ? null : redactText(String(candidate.cmd));
197
303
 
198
304
  const existing = candidates.find(
199
305
  (c) =>
200
306
  c.kind === kind &&
201
307
  c.signature === signature &&
202
- (sameEvidence(c.evidence ?? [], evidence) || (c.cmd && candidate.cmd && c.cmd === candidate.cmd && c.evidence?.[0] === evidence[0])),
308
+ (sameEvidence(c.evidence ?? [], evidence) || (c.cmd && cmd && c.cmd === cmd && c.evidence?.[0] === evidence[0])),
203
309
  );
204
310
  if (existing) return existing;
205
311
 
@@ -208,28 +314,26 @@ export async function appendLessonCandidate(root, candidate) {
208
314
  : [];
209
315
  const keywords = Array.isArray(candidate.keywords) && candidate.keywords.length > 0
210
316
  ? [...new Set(candidate.keywords.map(String).filter(Boolean))]
211
- : extractKeywords(`${errorLine} ${candidate.cmd ?? ''}`, paths);
317
+ : extractKeywords(`${errorLine} ${cmd ?? ''}`, paths);
212
318
 
213
- const nextIndex = candidates.length + 1;
214
319
  const record = {
215
320
  v: 1,
216
- id: candidate.id ?? `c-${String(nextIndex).padStart(4, '0')}`,
321
+ id: candidate.id ?? lessonIdFor({ kind, signature, evidence, cmd }),
217
322
  ts: candidate.ts ?? new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
218
323
  kind,
219
324
  signature,
220
325
  errorLine,
221
- summary: String(candidate.summary ?? (errorLine || `${kind} candidate`)).trim(),
222
- cmd: candidate.cmd ?? null,
326
+ summary: redactText(String(candidate.summary ?? (errorLine || `${kind} candidate`))).trim(),
327
+ cmd,
223
328
  paths,
224
329
  keywords,
225
330
  evidence,
226
- fixDiff: String(candidate.fixDiff ?? ''),
331
+ fixDiff: capFixDiff(redactText(candidate.fixDiff ?? '')),
227
332
  status: candidate.status ?? 'candidate',
228
333
  promotedTo: candidate.promotedTo ?? null,
229
334
  };
230
335
 
231
- candidates.push(record);
232
- await writeLessonCandidates(root, candidates);
336
+ await appendCandidateLine(root, record);
233
337
  return record;
234
338
  }
235
339
 
@@ -306,8 +410,8 @@ export async function captureFromLedger(root, entries = null) {
306
410
  let list = entries;
307
411
  if (!Array.isArray(list)) {
308
412
  try {
309
- const raw = await readFile(join(root, '.simt', 'ledger.jsonl'), 'utf8');
310
- list = raw
413
+ const { text } = await readStateFile(root, LEDGER_REL, [LEDGER_REL_LEGACY]);
414
+ list = (text ?? '')
311
415
  .split('\n')
312
416
  .map((s) => s.trim())
313
417
  .filter(Boolean)
@@ -481,12 +585,13 @@ export async function recordHookDenial(root, denial) {
481
585
  * @returns {string}
482
586
  */
483
587
  export function renderLessonMarkdown(candidate, options = {}) {
588
+ // No `**` fallback: a trigger with no paths is a promotion the caller
589
+ // refused, not a lesson about everything. `promoteLesson` rejects those;
590
+ // this renders exactly the paths it was given (review 0003 Q36).
484
591
  const paths =
485
592
  Array.isArray(options.paths) && options.paths.length > 0
486
593
  ? options.paths
487
- : Array.isArray(candidate.paths) && candidate.paths.length > 0
488
- ? candidate.paths
489
- : ['**'];
594
+ : (Array.isArray(candidate.paths) ? candidate.paths : []);
490
595
  const keywords =
491
596
  Array.isArray(options.keywords) && options.keywords.length > 0
492
597
  ? options.keywords
@@ -494,7 +599,7 @@ export function renderLessonMarkdown(candidate, options = {}) {
494
599
  ? candidate.keywords
495
600
  : extractKeywords(candidate.errorLine ?? candidate.summary ?? '', paths);
496
601
  const evidence = Array.isArray(candidate.evidence) ? candidate.evidence : [];
497
- const expires = options.expires ?? DEFAULT_EXPIRES;
602
+ const expires = options.expires ?? defaultExpires();
498
603
  const title = String(options.title ?? candidate.summary ?? candidate.errorLine ?? 'Lesson').trim();
499
604
 
500
605
  const pathsLiteral = paths.map((p) => JSON.stringify(String(p))).join(', ');
@@ -528,7 +633,7 @@ export function renderLessonMarkdown(candidate, options = {}) {
528
633
  * @param {string} text
529
634
  * @returns {object}
530
635
  */
531
- export function parseLessonFile(id, text) {
636
+ export function parseLessonFile(id, text, now = new Date()) {
532
637
  const { front: rawFront, body: rawBody } = splitFrontmatter(text);
533
638
  const front = rawFront ?? '';
534
639
  const body = rawBody.trim();
@@ -546,6 +651,7 @@ export function parseLessonFile(id, text) {
546
651
  : [];
547
652
  const expMatch = /^simt-expires:\s*(\S+)/m.exec(front);
548
653
  const expires = expMatch ? expMatch[1].trim() : null;
654
+ const expired = isExpired(expires, now);
549
655
 
550
656
  const titleMatch = /^#\s+(.+)$/m.exec(body);
551
657
  const title = titleMatch ? titleMatch[1].trim() : id;
@@ -566,16 +672,23 @@ export function parseLessonFile(id, text) {
566
672
  keywords,
567
673
  evidence,
568
674
  expires,
675
+ expired,
569
676
  };
570
677
  }
571
678
 
572
679
  /**
573
- * List all promoted lessons in `docs/lessons/*.md`.
680
+ * List the promoted lessons in `docs/lessons/*.md` that are still in force.
681
+ *
682
+ * `simt-expires` is read here, once, so every consumer — recall, `why`, the
683
+ * hook, the bench, `lessons list` — agrees on what has expired. Nothing was
684
+ * reading the field, so expired lessons kept firing indefinitely (review 0003
685
+ * Q36). Pass `includeExpired` to see the retired ones with `expired: true`.
574
686
  *
575
687
  * @param {string} root
688
+ * @param {{includeExpired?: boolean, now?: Date}} [options]
576
689
  * @returns {Promise<Array<object>>}
577
690
  */
578
- export async function listPromotedLessons(root) {
691
+ export async function listPromotedLessons(root, { includeExpired = false, now = new Date() } = {}) {
579
692
  const dir = join(root, LESSONS_DIR);
580
693
  let names = [];
581
694
  try {
@@ -587,7 +700,8 @@ export async function listPromotedLessons(root) {
587
700
  for (const name of names) {
588
701
  const text = await readFile(join(dir, name), 'utf8');
589
702
  const id = name.replace(/\.md$/, '');
590
- lessons.push(parseLessonFile(id, text));
703
+ const lesson = parseLessonFile(id, text, now);
704
+ if (!lesson.expired || includeExpired) lessons.push(lesson);
591
705
  }
592
706
  return lessons;
593
707
  }
@@ -612,17 +726,20 @@ async function nextLessonNumber(root) {
612
726
  }
613
727
 
614
728
  /**
615
- * Promote a lesson candidate from `.simt/lessons.jsonl` to `docs/lessons/NNNN-<slug>.md`.
729
+ * Promote a lesson candidate from the lessons ledger to `docs/lessons/NNNN-<slug>.md`.
730
+ *
731
+ * Capture is not promotion's business: `promoteLesson` used to scan the ledger
732
+ * first, so a preview (`review` without `--yes`) wrote to the store it was
733
+ * only reading. Capture is an explicit `lessons capture` now (review 0003 Q36).
616
734
  *
617
735
  * @param {string} root
618
- * @param {{id?: string, apply?: boolean, paths?: string[], expires?: string, title?: string}} [options]
736
+ * @param {{id?: string, apply?: boolean, paths?: string[], expires?: string, title?: string, journal?: object[]}} [options]
619
737
  * @returns {Promise<{ok: boolean, error?: string, candidate?: object, path?: string, markdown?: string, written?: boolean}>}
620
738
  */
621
739
  export async function promoteLesson(root, options = {}) {
622
- await captureFromLedger(root);
623
740
  const { candidates } = await readLessonCandidates(root);
624
741
  if (candidates.length === 0) {
625
- return { ok: false, error: 'no lesson candidates in .simt/lessons.jsonl' };
742
+ return { ok: false, error: `no lesson candidates in ${LESSONS_FILE}; capture some with: lessons capture` };
626
743
  }
627
744
 
628
745
  let target = null;
@@ -633,14 +750,31 @@ export async function promoteLesson(root, options = {}) {
633
750
  if (!target) {
634
751
  return { ok: false, error: `no lesson candidate matching "${options.id}"` };
635
752
  }
753
+ if (target.status === 'promoted') {
754
+ return { ok: false, error: `lesson candidate ${target.id} is already promoted to ${target.promotedTo}` };
755
+ }
636
756
  } else {
637
- target = candidates.find((c) => c.status !== 'promoted') ?? candidates[0];
757
+ // A promoted candidate is done: falling back to it re-promotes the same
758
+ // lesson under a new number, which is how one candidate became 0001/0002/0003.
759
+ target = candidates.find((c) => c.status !== 'promoted') ?? null;
760
+ if (!target) {
761
+ return { ok: false, error: 'nothing to promote: every candidate is already promoted' };
762
+ }
763
+ }
764
+
765
+ // No `**` default: a trigger that watches every path fires on every failure,
766
+ // which is the same as firing on none. A pathless candidate needs `--paths`.
767
+ const paths = Array.isArray(options.paths) && options.paths.length > 0
768
+ ? options.paths
769
+ : (Array.isArray(target.paths) ? target.paths : []);
770
+ if (paths.length === 0) {
771
+ return { ok: false, error: `lesson candidate ${target.id} names no paths; pass --paths to say what it governs` };
638
772
  }
639
773
 
640
774
  const num = await nextLessonNumber(root);
641
775
  const slug = slugify(options.title ?? target.errorLine ?? target.summary ?? target.kind);
642
776
  const relPath = `${LESSONS_DIR}/${num}-${slug}.md`;
643
- const markdown = renderLessonMarkdown(target, options);
777
+ const markdown = renderLessonMarkdown(target, { ...options, paths, expires: options.expires ?? defaultExpires() });
644
778
 
645
779
  // The `--diff` before-side, read before anything is written so the diff is true
646
780
  // in preview and in apply alike (R50). Always a creation in practice — the
@@ -657,6 +791,7 @@ export async function promoteLesson(root, options = {}) {
657
791
  const fullPath = join(root, relPath);
658
792
  await mkdir(dirname(fullPath), { recursive: true });
659
793
  await writeFile(fullPath, markdown, 'utf8');
794
+ if (Array.isArray(options.journal)) recordCreatedFile(root, options.journal, join(root, relPath), markdown);
660
795
  target.status = 'promoted';
661
796
  target.promotedTo = relPath;
662
797
  await writeLessonCandidates(root, candidates);
package/src/name.mjs CHANGED
@@ -32,6 +32,15 @@ export const MANIFEST_FILE = 'simt.json';
32
32
  /** What the manifest file was called, and still is in every adopted repo. */
33
33
  export const MANIFEST_LEGACY = 'simt-dev.json';
34
34
 
35
+ /** The projection lock: the input and output digests a frozen check compares, and the
36
+ * locked `variants` winners `simt bench variants` records (ADR-086). */
37
+ export const LOCK_FILE = 'simt.lock.json';
38
+
39
+ /** What the lock was called. Read, not written: an adopted repo's lock under the old
40
+ * name may hold variant winners nothing can regenerate, so it is read until the next
41
+ * write migrates it (ADR-110). */
42
+ export const LOCK_LEGACY = 'simt-dev.lock.json';
43
+
35
44
  /**
36
45
  * The namespace the generated region writes into a repository's files: the begin and end
37
46
  * markers, and the provenance header inside them. It follows the command, like the
@@ -20,10 +20,11 @@
20
20
  // the file was read. Status and dependency checks never touch it: a 1 MB
21
21
  // section that belongs to a later slice is not copied into the index.
22
22
 
23
- import { readdir, readFile, writeFile } from 'node:fs/promises';
23
+ import { readdir, readFile } from 'node:fs/promises';
24
24
  import { join } from 'node:path';
25
25
 
26
26
  import { CLI } from '../name.mjs';
27
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
27
28
  import { loadFormat } from './template.mjs';
28
29
 
29
30
  function clean(cell) {
@@ -100,10 +101,16 @@ function sliceKey(path, id) {
100
101
  * empty cell is the pipe that closes it. A further empty cell is real: the
101
102
  * evidence column is often blank, and stripping every trailing empty would
102
103
  * delete it and shift the state into the wrong column.
104
+ *
105
+ * A pipe inside a code span is content, not a separator: an evidence cell may
106
+ * carry a command line with a pipe in it, and splitting there shifts every
107
+ * later cell left by one (review 0003 Q25). Backticks toggle the span; a
108
+ * `\|` escape is content in either mode.
103
109
  */
104
110
  function splitRow(line) {
105
111
  const cells = [];
106
112
  let current = '';
113
+ let inCode = false;
107
114
  for (let i = 0; i < line.length; i += 1) {
108
115
  const char = line[i];
109
116
  if (char === '\\' && i + 1 < line.length) {
@@ -111,7 +118,12 @@ function splitRow(line) {
111
118
  i += 1;
112
119
  continue;
113
120
  }
114
- if (char === '|') {
121
+ if (char === '`') {
122
+ inCode = !inCode;
123
+ current += char;
124
+ continue;
125
+ }
126
+ if (char === '|' && !inCode) {
115
127
  cells.push(current);
116
128
  current = '';
117
129
  continue;
@@ -596,9 +608,16 @@ export async function planDone(root, id, { sha = null, test = null, state = null
596
608
  return { root, id, error: `${id} is no longer on line ${slice.line} of ${slice.docName}`, remedy: `run: ${CLI} plan status` };
597
609
  }
598
610
 
599
- const cells = splitRow(found.lines[found.index]);
600
- const width = Math.max(slice.columnCount, slice.stateIdx + 1, slice.evidenceIdx + 1, cells.length);
601
- while (cells.length < width) cells.push('');
611
+ const original = found.lines[found.index];
612
+ const cells = splitRow(original);
613
+ if (cells.length !== slice.columnCount) {
614
+ return {
615
+ root,
616
+ id,
617
+ error: `the row for ${id} has ${cells.length} cells; the table declares ${slice.columnCount}`,
618
+ remedy: `fix the row in ${slice.docName} — an unescaped pipe splits it — and retry`,
619
+ };
620
+ }
602
621
  const from = cells[slice.stateIdx] ?? '';
603
622
  cells[slice.stateIdx] = replaceState(from, display);
604
623
 
@@ -613,14 +632,18 @@ export async function planDone(root, id, { sha = null, test = null, state = null
613
632
  evidenceNote = 'no evidence column in this tracker; the sha was not recorded';
614
633
  }
615
634
 
616
- const rebuilt = `| ${cells.join(' | ')} |`;
617
- const original = found.lines[found.index];
635
+ // The rebuilt line keeps the original's ending: a CRLF file must not gain
636
+ // one LF-terminated line among its CRLF lines (review 0003 Q25).
637
+ const eol = original.endsWith('\r') ? '\r' : '';
638
+ const rebuilt = `| ${cells.join(' | ')} |${eol}`;
618
639
  const changedCells = splitRow(original).join('\0') !== cells.join('\0');
619
640
  const newLine = changedCells ? rebuilt : original;
620
641
  const wouldChange = newLine !== original;
621
642
  if (wouldChange && !dryRun) {
622
643
  found.lines[found.index] = newLine;
623
- await writeFile(slice.docPath, found.lines.join('\n'), 'utf8');
644
+ const journal = [];
645
+ await journaledWriteFile(root, journal, slice.docPath, found.lines.join('\n'));
646
+ await commitJournal(root, 'plan', journal);
624
647
  }
625
648
 
626
649
  const apply = `run: ${CLI} plan done ${id}${sha ? ` ${sha}` : ''}${display === format.doneWrite ? '' : ` --state ${JSON.stringify(display)}`}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
@@ -23,7 +23,7 @@ const KNOWN = new Set(['$comment', 'name', 'directories', 'id', 'columns', 'stat
23
23
  const ID_KEYS = new Set(['pattern', 'headers']);
24
24
  const COLUMN_KEYS = new Set(['state', 'depends', 'title', 'evidence']);
25
25
  const DIRECTORY_KEYS = new Set(['kind', 'word', 'path']);
26
- const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies']);
26
+ const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies', 'role']);
27
27
  const DEPENDENCY_KEYS = new Set(['empty', 'separators', 'notes', 'group']);
28
28
  const WAVE_KEYS = new Set(['label', 'members']);
29
29
  const SECTION_KEYS = new Set(['depth']);
@@ -148,6 +148,7 @@ function compileStates(source, value) {
148
148
  const write = new Map();
149
149
  const known = new Set();
150
150
  const satisfies = new Set();
151
+ const roles = new Map();
151
152
  const order = [];
152
153
  const stateNames = [];
153
154
  for (let i = 0; i < value.length; i += 1) {
@@ -162,6 +163,13 @@ function compileStates(source, value) {
162
163
  }
163
164
  if (!Array.isArray(entry.read) || entry.read.length === 0) return bad(source, `${where}.read must list the words that mean this state`);
164
165
  if (known.has(entry.id)) return bad(source, `state ${entry.id} is listed twice`);
166
+ if (entry.role !== undefined && (typeof entry.role !== 'string' || !/^[a-z][a-z0-9-]*$/.test(entry.role))) {
167
+ return bad(source, `${where}.role must be a lower-case word`);
168
+ }
169
+ if (typeof entry.role === 'string') {
170
+ if (roles.has(entry.role)) return bad(source, `role ${entry.role} is claimed twice`);
171
+ roles.set(entry.role, entry.write.trim());
172
+ }
165
173
  const display = entry.write.trim();
166
174
  const aliases = new Set([entry.id, display.toLowerCase()]);
167
175
  for (const alias of entry.read) {
@@ -182,7 +190,7 @@ function compileStates(source, value) {
182
190
  else if (entry.satisfies != null && entry.satisfies !== false) return bad(source, `${where}.satisfies must be true or false`);
183
191
  }
184
192
  if (satisfies.size === 0) return bad(source, 'one state must have satisfies true, or nothing can ever be ready');
185
- return { read, write, known, satisfies, order, stateNames };
193
+ return { read, write, known, satisfies, roles, order, stateNames };
186
194
  }
187
195
 
188
196
  function compile(body, source, root) {
@@ -262,7 +270,7 @@ function compile(body, source, root) {
262
270
 
263
271
  const byWord = new Map(directories.directories.map((dir) => [dir.word, dir.kind]));
264
272
  const doneWrite = body.states.find((entry) => entry.satisfies === true).write.trim();
265
- const { read, write } = states;
273
+ const { read, write, roles } = states;
266
274
 
267
275
  return {
268
276
  format: {
@@ -273,6 +281,12 @@ function compile(body, source, root) {
273
281
  order: states.order,
274
282
  ready,
275
283
  doneWrite,
284
+ // The display a command moves a row to when work on it starts: the state
285
+ // carrying the `started` role, or null when no state claims it (review
286
+ // 0003 Q23). A null here is not an error — the caller falls back to the
287
+ // in-progress spelling it can read, and `plan done` fails closed on a
288
+ // word the template never defined.
289
+ startedWrite: roles.get('started') ?? null,
276
290
  stateNames: states.stateNames,
277
291
  satisfies: states.satisfies,
278
292
  known: states.known,
@@ -292,9 +306,17 @@ function compile(body, source, root) {
292
306
  headingRe: new RegExp(`^(#{1,${depth}})\\s+(.*\\S)\\s*$`),
293
307
  readState(raw) {
294
308
  const plain = String(raw ?? '').replace(/\*\*/g, '').trim().toLowerCase();
309
+ if (plain === '') return 'unknown';
295
310
  if (read.has(plain)) return read.get(plain);
296
- const token = plain.split(/[\s(]/)[0];
297
- return read.get(token) ?? (token || 'unknown');
311
+ // A cell may carry a note after the state — "In progress (since Monday)".
312
+ // The state is the longest alias that prefixes the text: "in progress"
313
+ // must win over a shorter alias it starts, and a word no state claims
314
+ // is unknown, not a state (review 0003 Q25).
315
+ let best = '';
316
+ for (const alias of read.keys()) {
317
+ if (alias.length > best.length && plain.startsWith(alias)) best = alias;
318
+ }
319
+ return best !== '' ? read.get(best) : 'unknown';
298
320
  },
299
321
  canonical(input) {
300
322
  return write.get(String(input ?? '').trim().toLowerCase()) ?? null;
package/src/plugins.mjs CHANGED
@@ -32,7 +32,7 @@ import { desiredRegion } from './projection/rules.mjs';
32
32
  import { PACKAGE_NAME, BIN_REL } from './name.mjs';
33
33
 
34
34
  /** The kinds `simt plugin` builds, in the order the help line names them. */
35
- export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor'];
35
+ export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor', 'opencode'];
36
36
 
37
37
  /**
38
38
  * The package this code ships in: the runtime a generated package copies. Resolved
@@ -71,7 +71,11 @@ function rootFlag(kind) {
71
71
 
72
72
  /** Where the hooks document lands inside a package, relative to the package root. */
73
73
  function hooksPath(kind) {
74
- return kind === 'cursor' ? join('.cursor', 'hooks.json') : join('hooks', 'hooks.json');
74
+ if (kind === 'cursor') return join('.cursor', 'hooks.json');
75
+ // OpenCode loads a local plugin directory's root `server` file as the plugin, so the
76
+ // generated document is that file at the root rather than a hooks config to merge.
77
+ if (kind === 'opencode') return 'server.js';
78
+ return join('hooks', 'hooks.json');
75
79
  }
76
80
 
77
81
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
package/src/prbody.mjs CHANGED
@@ -21,11 +21,10 @@ import { readLedger, treeId } from './ledger/index.mjs';
21
21
  import { readCheckCache } from './status.mjs';
22
22
  import { readManifest } from './projection/manifest.mjs';
23
23
  import { splitFrontmatter } from './memory/decide.mjs';
24
+ import { decisionsDir } from './decisions/dir.mjs';
24
25
 
25
26
  const exec = promisify(execFile);
26
27
 
27
- const DECISIONS_DIR = join('docs', 'decisions');
28
-
29
28
  /**
30
29
  * The ref the body diffs against: the override when given, else the first
31
30
  * candidate that shares history with HEAD. Returns the ref, or null when the
@@ -98,15 +97,18 @@ async function changedPaths(root, base) {
98
97
  * reported with what it has rather than dropping the row.
99
98
  */
100
99
  async function changedDecisions(root, paths) {
101
- const names = paths.filter((p) => p.startsWith(`${DECISIONS_DIR}/`) && p.toLowerCase().endsWith('.md'));
100
+ // Q55: the prefix is the repository's own decisions directory, not a literal.
101
+ const relDir = await decisionsDir(root);
102
+ const absDir = join(root, relDir);
103
+ const names = paths.filter((p) => p.startsWith(`${relDir}/`) && p.toLowerCase().endsWith('.md'));
102
104
  try {
103
- await readdir(join(root, DECISIONS_DIR));
105
+ await readdir(absDir);
104
106
  } catch {
105
107
  return [];
106
108
  }
107
109
  const out = [];
108
110
  for (const name of names.sort()) {
109
- const id = /^ADR-\d{3,}/i.exec(name.slice(DECISIONS_DIR.length + 1))?.[0].toUpperCase() ?? name;
111
+ const id = /^ADR-\d{3,}/i.exec(name.slice(relDir.length + 1))?.[0].toUpperCase() ?? name;
110
112
  let title = id;
111
113
  let status = 'unknown';
112
114
  try {
@@ -76,7 +76,7 @@ trim_trailing_whitespace = false
76
76
  indent_style = space
77
77
  indent_size = 2
78
78
 
79
- [*.{py,pyi,java,kt,kts}]
79
+ [*.{py,pyi,java,kt,kts,rs}]
80
80
  indent_size = 4
81
81
 
82
82
  [{Makefile,*.mk}]
@@ -25,7 +25,7 @@ const SKILL_DIRS = ['.agents/skills', '.cursor/skills', '.claude/skills', '.open
25
25
  * share a name both stay, because dropping one would hide a trigger.
26
26
  *
27
27
  * @param {string} root
28
- * @returns {Promise<{name: string, trigger: string, dir: string}[]>}
28
+ * @returns {Promise<{name: string, trigger: string, dir: string, file: string}[]>}
29
29
  */
30
30
  export async function installedSkills(root) {
31
31
  const seen = new Set();
@@ -61,7 +61,7 @@ export async function installedSkills(root) {
61
61
  const fallback = entry.isDirectory() ? entry.name : entry.name.replace(/\.md$/, '');
62
62
  const name = typeof meta.name === 'string' && meta.name !== '' ? meta.name : fallback;
63
63
  const trigger = typeof meta.description === 'string' ? meta.description.replace(/\s+/g, ' ').trim() : '';
64
- skills.push({ name, trigger, dir });
64
+ skills.push({ name, trigger, dir, file: key });
65
65
  }
66
66
  }
67
67
  skills.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));