simt-dev 0.6.2 → 0.6.3

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 (76) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  5. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  6. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  7. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  8. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  9. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  10. package/docs/decisions/README.md +44 -0
  11. package/package.json +1 -1
  12. package/rules/mdc/preview-postflight.mdc +5 -6
  13. package/rules/stacks/android.md +7 -8
  14. package/rules/stacks/cloudflare-workers.md +1 -1
  15. package/rules/stacks/container.md +2 -0
  16. package/rules/stacks/jvm.md +1 -1
  17. package/rules/stacks/react-native.md +3 -5
  18. package/rules/stacks/react-web.md +9 -9
  19. package/src/agenthooks/decide.mjs +101 -10
  20. package/src/agenthooks/policy.mjs +81 -5
  21. package/src/agenthooks/shell.mjs +523 -0
  22. package/src/argv.mjs +54 -9
  23. package/src/commands/agenthook.mjs +25 -3
  24. package/src/commands/ci.mjs +28 -3
  25. package/src/commands/completions.mjs +27 -0
  26. package/src/commands/decide.mjs +14 -1
  27. package/src/commands/doctor.mjs +367 -0
  28. package/src/commands/explain.mjs +52 -0
  29. package/src/commands/help.mjs +66 -17
  30. package/src/commands/hooks.mjs +208 -60
  31. package/src/commands/ignore.mjs +152 -0
  32. package/src/commands/index.mjs +54 -100
  33. package/src/commands/init.mjs +82 -12
  34. package/src/commands/lease.mjs +136 -0
  35. package/src/commands/lessons.mjs +12 -0
  36. package/src/commands/mcp.mjs +2 -2
  37. package/src/commands/pack.mjs +9 -0
  38. package/src/commands/plan.mjs +13 -1
  39. package/src/commands/plugin.mjs +16 -1
  40. package/src/commands/prbody.mjs +45 -0
  41. package/src/commands/repoindex.mjs +3 -0
  42. package/src/commands/rules.mjs +145 -105
  43. package/src/commands/shared.mjs +36 -5
  44. package/src/commands/skills.mjs +293 -205
  45. package/src/commands/skillsinstall.mjs +100 -30
  46. package/src/commands/slice.mjs +353 -0
  47. package/src/commands/status.mjs +62 -0
  48. package/src/commands/sync.mjs +110 -15
  49. package/src/commands/undo.mjs +106 -0
  50. package/src/commands/upgrade.mjs +70 -8
  51. package/src/completions.mjs +191 -0
  52. package/src/diff.mjs +254 -0
  53. package/src/explain.mjs +258 -0
  54. package/src/federation/config.mjs +44 -0
  55. package/src/graph/load.mjs +14 -5
  56. package/src/hooks/dispatcher.mjs +110 -0
  57. package/src/hooks/gates.mjs +14 -3
  58. package/src/hooks/resolve.mjs +14 -0
  59. package/src/ignore.mjs +133 -0
  60. package/src/index/persist.mjs +66 -9
  61. package/src/journal.mjs +326 -0
  62. package/src/lease.mjs +113 -0
  63. package/src/mcp/server.mjs +15 -1
  64. package/src/mcp/tools.mjs +296 -256
  65. package/src/memory/decide.mjs +6 -1
  66. package/src/memory/lessons.mjs +12 -0
  67. package/src/output/index.mjs +82 -12
  68. package/src/package-version.mjs +49 -1
  69. package/src/packs/manage.mjs +13 -4
  70. package/src/plan/index.mjs +6 -0
  71. package/src/plugins.mjs +27 -6
  72. package/src/prbody.mjs +251 -0
  73. package/src/projection/manifest.mjs +13 -2
  74. package/src/projection/shared.mjs +4 -1
  75. package/src/secrets.mjs +231 -0
  76. package/src/status.mjs +152 -0
@@ -51,7 +51,11 @@ import {
51
51
  benchCriticCommand,
52
52
  } from './bench.mjs';
53
53
  import { planCommand } from './plan.mjs';
54
- import { sharedPlanCommand, sharedInstallCommand, sharedWarning } from './shared.mjs';
54
+ import { sliceCommand } from './slice.mjs';
55
+ import { leaseCommand } from './lease.mjs';
56
+ import { sharedPlanCommand, sharedInstallCommand } from './shared.mjs';
57
+ import { runDoctor } from './doctor.mjs';
58
+ import { statusCommand } from './status.mjs';
55
59
  import { evidenceCommand } from './evidence.mjs';
56
60
  import { indexCommand } from './repoindex.mjs';
57
61
  import { mapCommand } from './map.mjs';
@@ -61,13 +65,16 @@ import { gateCommand } from './gate.mjs';
61
65
  import { impactCommand } from './impact.mjs';
62
66
  import { lessonsListCommand, lessonsReviewCommand } from './lessons.mjs';
63
67
  import { decideCommand } from './decide.mjs';
68
+ import { explainCommand } from './explain.mjs';
69
+ import { undoCommand } from './undo.mjs';
70
+ import { prBodyCommand } from './prbody.mjs';
71
+ import { ignoreCommand } from './ignore.mjs';
72
+ import { completionsCommand } from './completions.mjs';
64
73
  import { reviewCommand } from './review.mjs';
65
74
  import { localDoctorCommand } from './local.mjs';
66
75
  import { distillCommand } from './distill.mjs';
67
- import { parse } from '../argv.mjs';
76
+ import { parse, rawJsonFlag } from '../argv.mjs';
68
77
  import { CLI } from '../name.mjs';
69
- import { fingerprint } from '../resolve/fingerprint.mjs';
70
- import { resolveWorkspace } from '../workspace.mjs';
71
78
 
72
79
  /** command path -> implementation sequence step (plan 0002 section 11). */
73
80
  // Every command named in plan 0002 section 5 is now implemented, so this map is empty.
@@ -83,6 +90,10 @@ const GROUPS = new Map([
83
90
  ['rules', 'list'],
84
91
  ['hooks', 'status'],
85
92
  ['plan', 'status'],
93
+ // Plan 0003 R53. Both halves write, so a bare `slice` has no read to default to.
94
+ // `start` is the default because with the id it is missing it fails with a usage
95
+ // error before anything is created — the one default here that cannot mutate.
96
+ ['slice', 'start'],
86
97
  ['agent-hooks', 'status'],
87
98
  ['harness', 'detect'],
88
99
  ['bench', 'mine'],
@@ -114,6 +125,9 @@ const GROUPS = new Map([
114
125
  export const IMPLEMENTED = {
115
126
  brief: briefCommand,
116
127
  doctor: runDoctor,
128
+ // Plan 0003 R48. A bare command: the only mode is `--line`, so there is no
129
+ // group and no default subcommand to resolve.
130
+ status: statusCommand,
117
131
  'graph lint': lintCommand,
118
132
  'skills index': skillsIndex,
119
133
  'skills find': skillsFind,
@@ -131,6 +145,11 @@ export const IMPLEMENTED = {
131
145
  'plan status': (args, flags) => planCommand('status', args, flags),
132
146
  'plan next': (args, flags) => planCommand('next', args, flags),
133
147
  'plan done': (args, flags) => planCommand('done', args, flags),
148
+ // Plan 0003 R53. A group: `start` isolates the work on a branch with its brief,
149
+ // `finish` runs the gates, commits and records the evidence. Both take the slice
150
+ // id as a positional.
151
+ 'slice start': (args, flags) => sliceCommand('start', args, flags),
152
+ 'slice finish': (args, flags) => sliceCommand('finish', args, flags),
134
153
  'shared plan': sharedPlanCommand,
135
154
  'shared install': sharedInstallCommand,
136
155
  evidence: evidenceCommand,
@@ -143,6 +162,22 @@ export const IMPLEMENTED = {
143
162
  'lessons list': lessonsListCommand,
144
163
  'lessons review': lessonsReviewCommand,
145
164
  decide: decideCommand,
165
+ // Plan 0003 R46. A bare command: the code is a positional, so there is no
166
+ // group and no default subcommand to resolve.
167
+ explain: explainCommand,
168
+ // Plan 0003 R49. A bare command: restoring the last journaled set needs no
169
+ // subcommand, so there is no group and no default to resolve.
170
+ undo: undoCommand,
171
+ // Plan 0003 R55. A bare command: the body is derived from the tree, the
172
+ // ledger and the last check, so there is no subcommand to resolve and no
173
+ // write to confirm.
174
+ 'pr-body': prBodyCommand,
175
+ // Plan 0003 R52. A bare command: the one list is `.simtignore`, so there is no
176
+ // group and no default subcommand to resolve.
177
+ ignore: ignoreCommand,
178
+ // Plan 0003 R54. A bare command: paths claim, no paths list, so the argument itself
179
+ // is the mode and there is no group to resolve.
180
+ lease: leaseCommand,
146
181
  distill: distillCommand,
147
182
  // R25 machinery needs the runnable command names for Affects command tokens,
148
183
  // same wiring `why` and `impact` get through their lambdas.
@@ -177,6 +212,9 @@ export const IMPLEMENTED = {
177
212
  // plan 0003 R43. A bare command: the kind is a positional, so there is no group and
178
213
  // no default subcommand to resolve.
179
214
  plugin: pluginCommand,
215
+ // Plan 0003 R51. A bare command: the shell is a positional. `COMMANDS` rides in
216
+ // from here so the engine keeps no second copy of the registry it completes.
217
+ completions: (args, flags) => completionsCommand(args, flags, COMMANDS),
180
218
  // Plan 0003 R45. A group, not a bare command: `new` and `publish` write (or
181
219
  // publish) and `lint` reads, and the dispatcher routes all three through one
182
220
  // subcommand switch the way `hooks` does.
@@ -192,7 +230,7 @@ export const IMPLEMENTED = {
192
230
  export const COMMANDS = Object.keys(IMPLEMENTED).sort();
193
231
 
194
232
  export async function dispatch(argv) {
195
- const { flags, positional } = parseOrExit(argv);
233
+ const { flags, positional, parseError } = parseOrExit(argv);
196
234
 
197
235
  // The JSON shape is decided once, here, and every command inherits it. Threading a
198
236
  // `--pretty` through twenty-five call sites would mean twenty-five places to forget,
@@ -205,6 +243,10 @@ export async function dispatch(argv) {
205
243
  jsonIndentDefault(flags.pretty === true);
206
244
  jsonFailMode(flags.json === true);
207
245
 
246
+ // A parse failure is already the whole answer: report it once, in the asked-for
247
+ // shape, rather than falling through to `unknown command` on the empty path.
248
+ if (parseError !== undefined) return fail(parseError.message, parseError.code);
249
+
208
250
  // Help is shown only when explicitly asked for. An unknown command must fail with
209
251
  // a usage code, not print 40 lines of prose and exit 0 — an agent that mistyped
210
252
  // needs to be told so, not handed a wall of text it may misread as output.
@@ -267,102 +309,14 @@ function commandPath(first, second) {
267
309
  }
268
310
 
269
311
  function parseOrExit(argv) {
312
+ // Unreachable from `bin` (it renders the failure itself and exits), but `dispatch`
313
+ // is also called directly: carry the failure as data so the dispatcher reports it
314
+ // once, in the asked-for shape, instead of printing here and again below.
270
315
  const parsed = parse(argv);
271
- if (!parsed.ok) return { flags: {}, positional: [] };
316
+ if (!parsed.ok) {
317
+ const json = rawJsonFlag(argv);
318
+ return { flags: { json }, positional: [], parseError: parsed.error };
319
+ }
272
320
  return parsed.value;
273
321
  }
274
322
 
275
- /**
276
- * Step 3 of the implementation sequence: classify a repo from marker files alone.
277
- * Deliberately shipped early — it needs no registry, no rules and no projection,
278
- * and it makes the tool useful before any content exists.
279
- */
280
- async function runDoctor(positional, flags) {
281
- const root = flags.root ?? positional[0] ?? process.cwd();
282
- const result = await fingerprint(root);
283
- // Which source answered the workspace question, and where it looked. A tool
284
- // that silently fell back to the wrong directory would make every downstream
285
- // answer confidently wrong, so the answer is reported rather than inferred
286
- // (ADR-014).
287
- const ws = await resolveWorkspace(flags);
288
- // The shared file this repository hands rules to (ADR-084). Asked here and nowhere else
289
- // because it is the one thing about a repository that is true of the reader's machine
290
- // rather than of the tree: `check` must not read `$HOME`, so `doctor` is the only command
291
- // that can notice a region that dropped rules into a file nobody has. Reported, never
292
- // blocking — a stale copy costs a reader some of their rules, which is worth knowing and
293
- // is not this repository's gate failing.
294
- const shared = await sharedWarning(root, result.manifest ?? null, flags);
295
-
296
- // Under `--json` the document is the whole report, so it has to carry both
297
- // failures the text path reports AND the workspace answer the text path prints.
298
- // This branch used to return before either check, with `ok` hardcoded true: a
299
- // caller asking whether their config is readable was told yes, and given exit
300
- // 0, while the same command in a terminal exited 3 and named the file. A
301
- // scripted consumer has only this document to go on, so `ok` is derived from
302
- // the exit code rather than written as a constant (S10).
303
- const failure = result.error
304
- ? { error: `${root}: ${result.error}`, remedy: null }
305
- : ws.error
306
- ? {
307
- error: `workspace config ${ws.detail}: ${ws.error}`,
308
- remedy: 'fix the JSON, or remove the file',
309
- }
310
- : null;
311
- const code = failure ? EXIT.CONFIG : EXIT.OK;
312
-
313
- if (isJson(flags)) {
314
- emitJson({
315
- ok: code === EXIT.OK,
316
- command: 'doctor',
317
- root,
318
- ...result,
319
- // The workspace answer is a fact about which directory the tool would
320
- // act on next (ADR-014). It is printed in the text report; leaving it
321
- // out of the JSON meant a machine-readable caller could not see it at
322
- // all, which is the one-question-two-answers defect again.
323
- workspace: {
324
- root: ws.root ?? null,
325
- source: ws.source,
326
- detail: ws.detail,
327
- error: ws.error ?? null,
328
- },
329
- shared,
330
- ...(failure ?? {}),
331
- });
332
- return code;
333
- }
334
- if (result.error) {
335
- return fail(`${root}: ${result.error}`, EXIT.CONFIG);
336
- }
337
- emit(
338
- [
339
- `root ${root}`,
340
- `stack ${result.stack.length > 0 ? result.stack.join(', ') : '(undetected)'}`,
341
- `evidence ${result.evidence.length > 0 ? result.evidence.join(' ') : '(none)'}`,
342
- // Facts are what a profile selects on when it wants to survive the
343
- // framework being replaced, so they are reported with the path that proved
344
- // each one rather than as a bare list (ADR-013).
345
- `facts ${
346
- result.facts.length > 0
347
- ? result.facts.map((f) => `${f}(${result.factEvidence[f] ?? '?'})`).join(' ')
348
- : '(none)'
349
- }`,
350
- `manifest ${result.manifest ? (result.manifest.error ?? result.manifest.id ?? 'present') : '(absent)'}`,
351
- `workspace ${ws.root ?? '(none)'} (${ws.source}: ${ws.detail})`,
352
- // Printed only when this repository handed rules to a workspace copy: a line in
353
- // every `doctor` is a line in every reader's terminal, and the reader who has
354
- // nothing to install is not served by one (ADR-084).
355
- ...(shared.declared ? [`shared ${shared.state} ${shared.detail}`] : []),
356
- ].join('\n'),
357
- { maxBytes: flags.maxBytes ?? 0 },
358
- );
359
- // An unconfigured workspace is a fact, not a failure: a single repo with no
360
- // workspace around it is a normal, supported case. A config file that EXISTS and
361
- // does not parse is a real mistake, and that does fail - with the parse error,
362
- // because naming the file without saying what is wrong with it sends the reader
363
- // to the file and gives them nothing to look for.
364
- if (ws.error) {
365
- return fail(`workspace config ${ws.detail}: ${ws.error}`, EXIT.CONFIG, 'fix the JSON, or remove the file');
366
- }
367
- return EXIT.OK;
368
- }
@@ -17,13 +17,16 @@
17
17
 
18
18
  import { execFile } from 'node:child_process';
19
19
  import { promisify } from 'node:util';
20
- import { mkdir, readFile, symlink, writeFile } from 'node:fs/promises';
20
+ import { mkdir, readFile, symlink } from 'node:fs/promises';
21
+ import { commitJournal, journaledWriteFile } from '../journal.mjs';
22
+ import { invalidateCheckCache } from '../status.mjs';
21
23
  import { dirname, join } from 'node:path';
22
24
 
23
- import { EXIT, emit, emitJson, fail, isJson, note } from '../output/index.mjs';
25
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
24
26
  import { fingerprint } from '../resolve/fingerprint.mjs';
25
27
  import { rulePaths, scanDocument, summarise } from '../projection/conflicts.mjs';
26
- import { MANIFEST, buildManifest, manifestPath, readManifest, writeManifest, planGates } from '../projection/manifest.mjs';
28
+ import { MANIFEST, buildManifest, manifestPath, manifestText, readManifest, writeManifest, planGates } from '../projection/manifest.mjs';
29
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
27
30
  import { readCapability } from '../hooks/capability.mjs';
28
31
  import { readHooksState } from '../compare/hooksstate.mjs';
29
32
  import { planWorkflow, renderWorkflow, WORKFLOWS_DIR } from '../ci/workflow.mjs';
@@ -105,9 +108,9 @@ export async function initCommand(positional, flags) {
105
108
  manifest.surfaces = projected.surfaces;
106
109
 
107
110
  const adoption = exists || Boolean(region?.exists);
108
- const dryRun = flags.dryRun === true;
109
- const approved = flags.yes === true;
110
- const apply = approved && !dryRun;
111
+ // One decider for `--yes --dry-run`: the inline copy used to agree with it today,
112
+ // but a second copy of the same arithmetic is where the next bypass grows (Q09).
113
+ const { dryRun, apply } = writeMode(flags);
111
114
 
112
115
  const manifestAction = exists ? 'update' : 'create';
113
116
  const manifestFile = await manifestPath(root);
@@ -280,11 +283,69 @@ export async function initCommand(positional, flags) {
280
283
  );
281
284
  }
282
285
 
286
+ // The `--diff` pairs, read before anything is written so the before side is
287
+ // true in preview and in apply alike (R50). The after-bytes reuse the exact
288
+ // render calls the apply path below writes, so the diff shows the write
289
+ // rather than a second opinion about it. Pointers carry no bytes, so the
290
+ // plan lines naming the link targets are already their whole story.
291
+ let diffs = [];
292
+ if (flags.diff === true) {
293
+ diffs = [
294
+ {
295
+ path: MANIFEST_FILE,
296
+ before: await readBefore(manifestFile),
297
+ after: manifestText(manifest),
298
+ },
299
+ ];
300
+ if (payload.identity.action === 'created') {
301
+ diffs.push({
302
+ path: IDENTITY_FILE,
303
+ before: null,
304
+ after: renderIdentityFile(identity),
305
+ });
306
+ }
307
+ if (payload.ci.action === 'create') {
308
+ diffs.push({
309
+ path: payload.ci.file,
310
+ before: await readBefore(join(root, payload.ci.file)),
311
+ after: renderWorkflow({ own: payload.ci.own, npm, version }),
312
+ });
313
+ }
314
+ if (payload.dependabot.action === 'create') {
315
+ diffs.push({
316
+ path: payload.dependabot.file,
317
+ before: await readBefore(join(root, payload.dependabot.file)),
318
+ after: renderDependabot(payload.dependabot.facts, {
319
+ stack: payload.dependabot.stack,
320
+ own: payload.dependabot.own,
321
+ }),
322
+ });
323
+ }
324
+ if (payload.tsconfigBase.action === 'create') {
325
+ diffs.push({
326
+ path: payload.tsconfigBase.file,
327
+ before: await readBefore(join(root, payload.tsconfigBase.file)),
328
+ after: renderTsconfigBase(),
329
+ });
330
+ }
331
+ for (const target of projected.targets) {
332
+ if (target.action === 'unchanged' || target.action === 'malformed') continue;
333
+ diffs.push({
334
+ path: target.file,
335
+ before: await readBefore(target.path),
336
+ after: target.text,
337
+ });
338
+ }
339
+ Object.assign(payload, diffField(diffs, flags));
340
+ }
341
+
283
342
  // Step 4/5: report everything, then make changes only when told to.
284
343
  if (isJson(flags)) {
285
344
  if (!apply) emitJson(payload);
286
345
  } else {
287
346
  emit(render(payload, manifest, { npm, version }));
347
+ // After the report: the report says what, the diff shows it (R50).
348
+ emitDiffs(diffs, flags);
288
349
  }
289
350
  if (!apply) {
290
351
  note('');
@@ -331,7 +392,10 @@ export async function initCommand(positional, flags) {
331
392
  note(`branch switched to ${git.branch}; ${git.from} is untouched and \`git checkout ${git.from}\` returns to it`);
332
393
  }
333
394
 
334
- await writeManifest(root, manifest);
395
+ // Every repo-file write below is journaled, so `undo` can restore this run
396
+ // (R49). Pointer symlinks carry no bytes and are not journaled.
397
+ const journal = [];
398
+ await writeManifest(root, manifest, journal);
335
399
  note(`wrote ${MANIFEST_FILE} (${manifestAction})`);
336
400
 
337
401
  // Identity last, and for the same reason the gate is written after the manifest:
@@ -345,7 +409,7 @@ export async function initCommand(positional, flags) {
345
409
  // because replacing it when it is already right would be a no-op that reads as
346
410
  // a change.
347
411
  if (payload.identity.action === 'created') {
348
- await writeFile(join(root, IDENTITY_FILE), renderIdentityFile(identity));
412
+ await journaledWriteFile(root, journal, join(root, IDENTITY_FILE), renderIdentityFile(identity));
349
413
  await ignoreIdentity(root);
350
414
  note(`wrote ${IDENTITY_FILE} (${identity.from === 'last-commit' ? 'from the last commit in this repository' : 'from the identity configured on this machine'}); gitignored, because it is yours and not the repository's`);
351
415
  } else if (payload.identity.action === 'unchanged') {
@@ -369,7 +433,7 @@ export async function initCommand(positional, flags) {
369
433
  // guaranteed to be misled.
370
434
  if (payload.ci.action === 'create') {
371
435
  await mkdir(dirname(join(root, payload.ci.file)), { recursive: true });
372
- await writeFile(join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own, npm, version }));
436
+ await journaledWriteFile(root, journal, join(root, payload.ci.file), renderWorkflow({ own: payload.ci.own, npm, version }));
373
437
  note(`wrote ${payload.ci.file} (create; yours to edit from here, simt-dev will not rewrite it)`);
374
438
  }
375
439
  if (payload.dependabot.action === 'create') {
@@ -378,7 +442,9 @@ export async function initCommand(positional, flags) {
378
442
  // in the plan below are the bytes written here, and re-detecting would make the
379
443
  // printed entry list and the written one disagree the moment a manifest appears
380
444
  // between the two calls.
381
- await writeFile(
445
+ await journaledWriteFile(
446
+ root,
447
+ journal,
382
448
  join(root, payload.dependabot.file),
383
449
  renderDependabot(payload.dependabot.facts, {
384
450
  stack: payload.dependabot.stack,
@@ -390,7 +456,7 @@ export async function initCommand(positional, flags) {
390
456
  );
391
457
  }
392
458
  if (payload.tsconfigBase.action === 'create') {
393
- await writeFile(join(root, payload.tsconfigBase.file), renderTsconfigBase());
459
+ await journaledWriteFile(root, journal, join(root, payload.tsconfigBase.file), renderTsconfigBase());
394
460
  note(
395
461
  `wrote ${payload.tsconfigBase.file} (create; ${TSCONFIG_BASE_FILE} is JSON, so it cannot say this itself — add "extends": "./${TSCONFIG_BASE_FILE}" to your tsconfig.json or nothing reads it)`,
396
462
  );
@@ -418,7 +484,7 @@ export async function initCommand(positional, flags) {
418
484
  // no such configuration at all will not have. Creating them is part of adopting,
419
485
  // not a detail of writing the file.
420
486
  await mkdir(dirname(target.path), { recursive: true });
421
- await writeFile(target.path, target.text);
487
+ await journaledWriteFile(root, journal, target.path, target.text);
422
488
  note(`wrote ${target.file} (${target.action})`);
423
489
  }
424
490
 
@@ -441,6 +507,10 @@ export async function initCommand(positional, flags) {
441
507
  for (const p of pointerBlocked) {
442
508
  note(`BLOCKED ${p.file}: ${p.detail} (${p.bytes} bytes); left as it is`);
443
509
  }
510
+ await commitJournal(root, 'init', journal);
511
+ // The tree changed, so the last `check` no longer describes it: the line
512
+ // goes back to "no cache" rather than reporting the tree as it was (R48).
513
+ await invalidateCheckCache(root);
444
514
  payload.written = true;
445
515
 
446
516
  if (isJson(flags)) emitJson(payload);
@@ -0,0 +1,136 @@
1
+ // `simt lease [<paths>...]`: claim files for one session, or read the claims (R54).
2
+ //
3
+ // The claim is the half of ADR-101 a session does by hand: `lease src/a.mjs --session
4
+ // alpha` records that alpha is working on the path until a TTL expires, the pre-edit
5
+ // hook reads the same file to refuse anyone else, and a bare `lease` lists what is
6
+ // currently held — because the first question a blocked session asks is "who has
7
+ // this", and opening a JSON file by hand is not an answer the tool should outsource.
8
+ //
9
+ // Two refusals are deliberate and both come from ADR-101: a path another session
10
+ // still holds is not taken (stealing is what the TTL is for, and the error names the
11
+ // holder and the expiry), and a claim file that will not parse is not overwritten
12
+ // (the hook fails closed on that same file, so writing over it would erase the very
13
+ // claims the hook is about to enforce while telling the author it fixed something).
14
+
15
+ import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
16
+ import { DEFAULT_TTL_SECONDS, activeLeases, leasePathFor, readLeases, writeLeases } from '../lease.mjs';
17
+ import { SESSION_ENV } from '../agenthooks/policy.mjs';
18
+ import { CLI } from '../name.mjs';
19
+
20
+ const ISO = (ms) => new Date(ms).toISOString();
21
+
22
+ export async function leaseCommand(args, flags) {
23
+ const root = flags.root ?? process.cwd();
24
+
25
+ // A session or a TTL with no paths is a claim about nothing; the parser would
26
+ // otherwise hand back a list the caller never asked to see.
27
+ if (args.length === 0) {
28
+ if (flags.session !== undefined || flags.ttl !== undefined) {
29
+ return fail(`lease without paths is a list, so it takes neither --session nor --ttl`, EXIT.USAGE, `run: ${CLI} lease <paths> --session <id>`);
30
+ }
31
+ return listLeases(root, flags);
32
+ }
33
+
34
+ const session = String(flags.session ?? process.env[SESSION_ENV] ?? '');
35
+ if (session === '') {
36
+ return fail(`lease needs a session to hold the claim`, EXIT.USAGE, `run: ${CLI} lease <paths> --session <id> (or set ${SESSION_ENV})`);
37
+ }
38
+ const ttl = flags.ttl === undefined ? DEFAULT_TTL_SECONDS : Number(flags.ttl);
39
+ if (!Number.isInteger(ttl) || ttl < 1) {
40
+ return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease <paths> --ttl 900`);
41
+ }
42
+
43
+ const wanted = [];
44
+ const seen = new Set();
45
+ for (const arg of args) {
46
+ const rel = leasePathFor(root, arg);
47
+ if (rel === null) {
48
+ return fail(`${arg} is not a path inside the repository`, EXIT.USAGE, `leases are repository-relative: run: ${CLI} lease <path> --session ${session}`);
49
+ }
50
+ if (!seen.has(rel)) {
51
+ seen.add(rel);
52
+ wanted.push(rel);
53
+ }
54
+ }
55
+
56
+ const read = await readLeases(root);
57
+ if (read.error !== null) {
58
+ return fail(`${read.error}`, EXIT.FAIL, `fix or remove ${read.path}; the pre-edit hook fails closed on it too`);
59
+ }
60
+ const held = activeLeases(read);
61
+ const conflicts = held.filter((l) => seen.has(l.path) && l.session !== session);
62
+ if (conflicts.length > 0) {
63
+ return fail(
64
+ `${conflicts.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; ')}`,
65
+ EXIT.FAIL,
66
+ `wait for the TTL or ask the holder to release it; run: ${CLI} lease to see every claim`,
67
+ );
68
+ }
69
+
70
+ const expiresAt = Date.now() + ttl * 1000;
71
+ const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
72
+ const mode = writeMode(flags);
73
+ const mine = wanted.map((path) => ({ path, session, expiresAt }));
74
+
75
+ if (!mode.apply) {
76
+ if (isJson(flags)) {
77
+ emitJson({
78
+ ok: true,
79
+ command: 'lease',
80
+ root,
81
+ dryRun: true,
82
+ session,
83
+ ttl,
84
+ leases: mine,
85
+ remedy: apply,
86
+ });
87
+ return EXIT.OK;
88
+ }
89
+ const lines = [
90
+ `repo ${root}`,
91
+ `session ${session}`,
92
+ `ttl ${ttl}s`,
93
+ ...mine.map((l) => `would lease ${l.path} until ${ISO(l.expiresAt)}`),
94
+ `apply ${apply}`,
95
+ ];
96
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
97
+ return EXIT.OK;
98
+ }
99
+
100
+ // Keep every other live claim, refresh ours, and let an expired one fall out: the
101
+ // write is also the only sweep the file ever gets.
102
+ const others = held.filter((l) => !seen.has(l.path));
103
+ const path = await writeLeases(root, [...others, ...mine]);
104
+
105
+ if (isJson(flags)) {
106
+ emitJson({ ok: true, command: 'lease', root, session, ttl, dryRun: false, leases: mine, file: path });
107
+ return EXIT.OK;
108
+ }
109
+ const lines = [
110
+ `repo ${root}`,
111
+ `session ${session}`,
112
+ `ttl ${ttl}s`,
113
+ ...mine.map((l) => `LEASED ${l.path} until ${ISO(l.expiresAt)}`),
114
+ ];
115
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
116
+ return EXIT.OK;
117
+ }
118
+
119
+ async function listLeases(root, flags) {
120
+ const read = await readLeases(root);
121
+ if (read.error !== null) {
122
+ return fail(`${read.error}`, EXIT.FAIL, `fix or remove ${read.path}`);
123
+ }
124
+ const held = activeLeases(read);
125
+ if (isJson(flags)) {
126
+ emitJson({ ok: true, command: 'lease', root, leases: held });
127
+ return EXIT.OK;
128
+ }
129
+ const lines = [
130
+ `repo ${root}`,
131
+ ...held.map((l) => `LEASED ${l.path} ${l.session} until ${ISO(l.expiresAt)}`),
132
+ ];
133
+ if (held.length === 0) lines.push(`(no active leases)`);
134
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
135
+ return EXIT.OK;
136
+ }
@@ -4,6 +4,7 @@
4
4
  import { resolve } from 'node:path';
5
5
 
6
6
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
7
+ import { diffField, emitDiffs } from '../diff.mjs';
7
8
  import { CLI } from '../name.mjs';
8
9
  import { parsePathList } from '../argv.mjs';
9
10
  import {
@@ -95,6 +96,14 @@ export async function lessonsReviewCommand(args, flags) {
95
96
  return fail(result.error, EXIT.FAIL, `record a session failure-then-fix or run: ${CLI} lessons`);
96
97
  }
97
98
 
99
+ // The `--diff` pair: the promoted lesson file, created or refreshed (R50). The
100
+ // before-side rides on the result from `promoteLesson`, read before anything
101
+ // was written, so the diff is true in preview and in apply alike.
102
+ const diffs =
103
+ flags.diff === true
104
+ ? [{ path: result.path, before: result.before ?? null, after: result.markdown }]
105
+ : [];
106
+
98
107
  const payload = {
99
108
  ok: true,
100
109
  command: 'lessons review',
@@ -104,6 +113,7 @@ export async function lessonsReviewCommand(args, flags) {
104
113
  path: result.path,
105
114
  candidate: result.candidate,
106
115
  markdown: result.markdown,
116
+ ...diffField(diffs, flags),
107
117
  };
108
118
 
109
119
  if (isJson(flags)) {
@@ -120,6 +130,8 @@ export async function lessonsReviewCommand(args, flags) {
120
130
  ];
121
131
 
122
132
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
133
+ // After the report: the report says what, the diff shows it (R50).
134
+ emitDiffs(diffs, flags);
123
135
  if (!mode.apply) {
124
136
  note(`note dry-run nothing written; apply with: ${CLI} lessons review --yes`);
125
137
  }
@@ -45,10 +45,10 @@ export async function mcpCommand(positional, flags) {
45
45
  [
46
46
  `simt-dev ${version} - MCP surface over stdio (protocols ${SUPPORTED_VERSIONS.join(', ')})`,
47
47
  '',
48
- `${TOOLS.length} read-only tools:`,
48
+ `${TOOLS.length} tools (all read-only except federation_query, which starts a declared server):`,
49
49
  ...TOOLS.map((t) => ` ${t.name.padEnd(16)} ${t.description.split('.')[0]}`),
50
50
  '',
51
- 'No tool here writes. init, sync, hooks install, skills install and upgrade stay in the',
51
+ 'Nothing here writes to your repositories. init, sync, hooks install, skills install and upgrade stay in the',
52
52
  'CLI, because each is meant to be read as a diff by a human before it touches anything.',
53
53
  '',
54
54
  `serve them with: ${positional[0] ?? `${CLI} mcp`}`,
@@ -27,6 +27,7 @@ import {
27
27
  writeScaffold,
28
28
  } from '../packs/manage.mjs';
29
29
  import { readManifest } from '../projection/manifest.mjs';
30
+ import { diffField, emitDiffs } from '../diff.mjs';
30
31
 
31
32
  export async function packCommand(sub, args, flags) {
32
33
  if (sub === 'new') return packNew(args, flags);
@@ -100,6 +101,9 @@ async function packNew(args, flags) {
100
101
  const dir = join(resolve(flags.root ?? process.cwd()), name);
101
102
  const files = scaffoldPack({ name, source });
102
103
  const { apply, dryRun } = writeMode(flags);
104
+ // A scaffold always creates into a directory that must not exist, so every
105
+ // before side is empty and each pair is a whole file (R50).
106
+ const diffs = flags.diff === true ? files.map((f) => ({ path: `${name}/${f.path}`, before: null, after: f.text })) : [];
103
107
 
104
108
  if (isJson(flags)) {
105
109
  emitJson({
@@ -110,6 +114,7 @@ async function packNew(args, flags) {
110
114
  files: files.map((f) => f.path),
111
115
  packFile: PACK_JSON,
112
116
  benchFile: BENCH_JSON,
117
+ ...diffField(diffs, flags),
113
118
  });
114
119
  return EXIT.OK;
115
120
  }
@@ -126,6 +131,8 @@ async function packNew(args, flags) {
126
131
  ].join('\n'),
127
132
  { maxBytes: flags.maxBytes ?? 0 },
128
133
  );
134
+ // After the report: the report says what, the diff shows it (R50).
135
+ emitDiffs(diffs, flags);
129
136
  return EXIT.OK;
130
137
  }
131
138
 
@@ -144,6 +151,8 @@ async function packNew(args, flags) {
144
151
  ].join('\n'),
145
152
  { maxBytes: flags.maxBytes ?? 0 },
146
153
  );
154
+ // After the report: the report says what, the diff shows it (R50).
155
+ emitDiffs(diffs, flags);
147
156
  return EXIT.OK;
148
157
  }
149
158
 
@@ -1,5 +1,7 @@
1
+ import { relative } from 'node:path';
1
2
  import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
2
3
  import { formatCounts, planDone, planNext, planStatus } from '../plan/index.mjs';
4
+ import { diffField, emitDiffs } from '../diff.mjs';
3
5
  import { CLI } from '../name.mjs';
4
6
 
5
7
  // Preview unless --yes, and --dry-run wins even then. A tracker row marked done
@@ -79,8 +81,16 @@ async function runDone(root, id, sha, flags) {
79
81
  dryRun: !mode.apply,
80
82
  });
81
83
  if (result.error) return fail(result.error, EXIT.USAGE, result.remedy);
84
+ // The `--diff` pair: the one changed tracker row (R50). The sides ride on the
85
+ // result from `planDone`, captured pre-write, so the diff is true in preview
86
+ // and in apply alike. When nothing changed the sides are equal and the diff
87
+ // renders empty.
88
+ const diffs =
89
+ flags.diff === true && result.beforeLine !== undefined
90
+ ? [{ path: relative(root, result.file), before: result.beforeLine, after: result.afterLine }]
91
+ : [];
82
92
  if (isJson(flags)) {
83
- emitJson({ ok: true, command: 'plan done', ...result });
93
+ emitJson({ ok: true, command: 'plan done', ...result, ...diffField(diffs, flags) });
84
94
  return EXIT.OK;
85
95
  }
86
96
  const lines = [`repo ${root}`];
@@ -95,5 +105,7 @@ async function runDone(root, id, sha, flags) {
95
105
  if (result.evidenceRecorded) lines.push(`evidence ${result.sha}${result.test ? ` test: ${result.test}` : ''}`);
96
106
  else if (result.evidenceNote) lines.push(`note ${result.evidenceNote}`);
97
107
  emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
108
+ // After the report: the report says what, the diff shows it (R50).
109
+ emitDiffs(diffs, flags);
98
110
  return EXIT.OK;
99
111
  }