simt-dev 0.6.0 → 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 (79) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +44 -15
  3. package/bin/simt.mjs +19 -6
  4. package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
  5. package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
  6. package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
  7. package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
  8. package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
  9. package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
  10. package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
  11. package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
  12. package/docs/decisions/README.md +44 -0
  13. package/package.json +1 -1
  14. package/rules/mdc/preview-postflight.mdc +5 -6
  15. package/rules/stacks/android.md +7 -8
  16. package/rules/stacks/cloudflare-workers.md +1 -1
  17. package/rules/stacks/container.md +2 -0
  18. package/rules/stacks/jvm.md +1 -1
  19. package/rules/stacks/react-native.md +3 -5
  20. package/rules/stacks/react-web.md +9 -9
  21. package/src/agenthooks/decide.mjs +101 -10
  22. package/src/agenthooks/policy.mjs +81 -5
  23. package/src/agenthooks/shell.mjs +523 -0
  24. package/src/argv.mjs +54 -9
  25. package/src/ci/workflow.mjs +7 -5
  26. package/src/commands/agenthook.mjs +25 -3
  27. package/src/commands/ci.mjs +28 -3
  28. package/src/commands/completions.mjs +27 -0
  29. package/src/commands/decide.mjs +14 -1
  30. package/src/commands/doctor.mjs +367 -0
  31. package/src/commands/explain.mjs +52 -0
  32. package/src/commands/help.mjs +66 -17
  33. package/src/commands/hooks.mjs +208 -60
  34. package/src/commands/ignore.mjs +152 -0
  35. package/src/commands/index.mjs +54 -100
  36. package/src/commands/init.mjs +82 -12
  37. package/src/commands/lease.mjs +136 -0
  38. package/src/commands/lessons.mjs +12 -0
  39. package/src/commands/mcp.mjs +2 -2
  40. package/src/commands/pack.mjs +9 -0
  41. package/src/commands/plan.mjs +13 -1
  42. package/src/commands/plugin.mjs +16 -1
  43. package/src/commands/prbody.mjs +45 -0
  44. package/src/commands/repoindex.mjs +3 -0
  45. package/src/commands/rules.mjs +145 -105
  46. package/src/commands/shared.mjs +36 -5
  47. package/src/commands/skills.mjs +293 -205
  48. package/src/commands/skillsinstall.mjs +100 -30
  49. package/src/commands/slice.mjs +353 -0
  50. package/src/commands/status.mjs +62 -0
  51. package/src/commands/sync.mjs +110 -15
  52. package/src/commands/undo.mjs +106 -0
  53. package/src/commands/upgrade.mjs +70 -8
  54. package/src/completions.mjs +191 -0
  55. package/src/diff.mjs +254 -0
  56. package/src/explain.mjs +258 -0
  57. package/src/federation/config.mjs +44 -0
  58. package/src/graph/load.mjs +14 -5
  59. package/src/hooks/dispatcher.mjs +110 -0
  60. package/src/hooks/gates.mjs +14 -3
  61. package/src/hooks/resolve.mjs +14 -0
  62. package/src/ignore.mjs +133 -0
  63. package/src/index/persist.mjs +66 -9
  64. package/src/journal.mjs +326 -0
  65. package/src/lease.mjs +113 -0
  66. package/src/mcp/server.mjs +15 -1
  67. package/src/mcp/tools.mjs +296 -256
  68. package/src/memory/decide.mjs +6 -1
  69. package/src/memory/lessons.mjs +12 -0
  70. package/src/output/index.mjs +82 -12
  71. package/src/package-version.mjs +49 -1
  72. package/src/packs/manage.mjs +13 -4
  73. package/src/plan/index.mjs +6 -0
  74. package/src/plugins.mjs +27 -6
  75. package/src/prbody.mjs +251 -0
  76. package/src/projection/manifest.mjs +13 -2
  77. package/src/projection/shared.mjs +4 -1
  78. package/src/secrets.mjs +231 -0
  79. package/src/status.mjs +152 -0
@@ -20,15 +20,17 @@
20
20
  // 3. References come with the skill. A skill whose reference files are missing
21
21
  // is worse than one that was never installed, because it looks installed.
22
22
 
23
- import { cp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
23
+ import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
24
+ import { commitJournal, journaledCopyDir, journaledWriteFile } from '../journal.mjs';
25
+ import { diffField, emitDiffs, readBefore } from '../diff.mjs';
24
26
  import { existsSync } from 'node:fs';
25
- import { dirname, join } from 'node:path';
27
+ import { dirname, join, relative, sep } from 'node:path';
26
28
 
27
29
  import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
28
30
  import { loadRegistry, measureSkill } from '../graph/load.mjs';
29
31
  import { fingerprint } from '../resolve/fingerprint.mjs';
30
32
  import { readCapability } from '../hooks/capability.mjs';
31
- import { MANIFEST, buildManifest, readManifest, writeManifest } from '../projection/manifest.mjs';
33
+ import { MANIFEST, buildManifest, manifestPath, manifestText, readManifest, writeManifest } from '../projection/manifest.mjs';
32
34
  import { provenanceDigest } from '../projection/project.mjs';
33
35
  import { locateSource, sourceHeldLocally } from '../resolve/source.mjs';
34
36
  import { nearMatches } from '../resolve/near.mjs';
@@ -161,10 +163,18 @@ export async function installCommand(args, flags) {
161
163
  // `--yes --json` a command that reported a plan and changed nothing - the one
162
164
  // combination a script writes. The payload is now emitted once, at the end,
163
165
  // after the write it describes.
166
+ const mode = writeMode(flags);
167
+ // The `--diff` pairs, read before anything is written so the before side is
168
+ // true in preview and in apply alike (R50). Built before the early returns
169
+ // below so the shared `report()` can carry the field in every mode.
170
+ const diffs = flags.diff === true ? await skillDiffs(root, { src, dest, destRel: payload.dest, flat: measured.flat, name, record, repo }) : [];
164
171
  const report = () => {
165
- if (isJson(flags)) emitJson(payload);
172
+ // `--json` used to return here, before anything was written, which made
173
+ // `--yes --json` a command that reported a plan and changed nothing - the one
174
+ // combination a script writes. The payload is now emitted once, at the end,
175
+ // after the write it describes.
176
+ if (isJson(flags)) emitJson({ ...payload, ...diffField(diffs, flags) });
166
177
  };
167
- const mode = writeMode(flags);
168
178
 
169
179
  if (!isJson(flags)) {
170
180
  emit(
@@ -213,18 +223,22 @@ export async function installCommand(args, flags) {
213
223
  // "nothing written" and then wrote. It belongs on the path that does not write.
214
224
  if (!mode.apply) {
215
225
  note(`note dry-run nothing written; apply with: ${CLI} skills install ${name} --yes`);
226
+ // After the report: the report says what, the diff shows it (R50).
227
+ emitDiffs(diffs, flags);
216
228
  report();
217
229
  return EXIT.OK;
218
230
  }
219
231
 
232
+ // Every repo write below is journaled, so `undo` restores this run (R49).
233
+ const journal = [];
220
234
  await mkdir(dirname(join(dest, 'SKILL.md')), { recursive: true });
221
235
  try {
222
236
  if (measured.flat) {
223
- await writeFile(join(dest, 'SKILL.md'), await wrapFlat(src, name), 'utf8');
237
+ await journaledWriteFile(root, journal, join(dest, 'SKILL.md'), await wrapFlat(src, name));
224
238
  } else {
225
239
  // References come with the skill: a skill whose reference files are missing
226
240
  // is worse than one that was never installed, because it looks installed.
227
- await cp(src, dest, { recursive: true });
241
+ await journaledCopyDir(root, journal, src, dest);
228
242
  }
229
243
  } catch (error) {
230
244
  return fail(
@@ -236,12 +250,15 @@ export async function installCommand(args, flags) {
236
250
  payload.written = true;
237
251
  note(`wrote ${payload.dest}`);
238
252
 
239
- const recorded = await recordLocal(root, record, skill.name, repo);
253
+ const recorded = await recordLocal(root, record, skill.name, repo, journal);
240
254
  note(`${recorded.created ? 'created' : 'updated'} ${MANIFEST_FILE} localSkills`);
241
255
  if (recorded.created) {
242
256
  note(`note this repo had no manifest; ${CLI} init still needs to run to project the rules`);
243
257
  }
244
258
  note(`note ${name} is now repo-local and is excluded from central-drift accounting`);
259
+ await commitJournal(root, 'skills install', journal);
260
+ // After the report: the report says what, the diff shows it (R50).
261
+ emitDiffs(diffs, flags);
245
262
  report();
246
263
  return EXIT.OK;
247
264
  }
@@ -502,29 +519,82 @@ async function fileSha(path) {
502
519
  * default gates in it is a promise the repo cannot keep, and a command that installs one
503
520
  * skill is not entitled to make it.
504
521
  */
505
- async function recordLocal(root, record, name, repo) {
522
+ /** Every regular file under a directory, absolute paths. Missing dir → []. */
523
+ async function treeFiles(dir) {
524
+ let st;
525
+ try {
526
+ st = await stat(dir);
527
+ } catch (error) {
528
+ if (error.code === 'ENOENT') return [];
529
+ throw error;
530
+ }
531
+ if (!st.isDirectory()) return [dir];
532
+ const out = [];
533
+ for (const name of await readdir(dir)) {
534
+ out.push(...(await treeFiles(join(dir, name))));
535
+ }
536
+ return out;
537
+ }
538
+
539
+ function rel(root, path) {
540
+ return relative(root, path).split(sep).join('/');
541
+ }
542
+
543
+ /**
544
+ * The `--diff` pairs for an install: the skill files plus the manifest record
545
+ * (plan 0003 R50). The manifest pair needs the same object the write applies,
546
+ * so the plan is shared with `recordLocal` rather than derived twice.
547
+ */
548
+ async function skillDiffs(root, { src, dest, destRel, flat, name, record, repo }) {
549
+ const pairs = [];
550
+ if (flat) {
551
+ pairs.push({
552
+ path: `${destRel}/SKILL.md`,
553
+ before: await readBefore(join(dest, 'SKILL.md')),
554
+ after: await wrapFlat(src, name),
555
+ });
556
+ } else {
557
+ // References come with the skill, so the diff walks the whole tree: only
558
+ // changed files render, the rest filter out as equal.
559
+ for (const abs of await treeFiles(src)) {
560
+ const destAbs = join(dest, relative(src, abs));
561
+ pairs.push({ path: rel(root, destAbs), before: await readBefore(destAbs), after: await readFile(abs, 'utf8') });
562
+ }
563
+ }
564
+ const planned = await planLocalRecord(root, record, name, repo);
565
+ if (!planned.error) {
566
+ pairs.push({
567
+ path: rel(root, await manifestPath(root)),
568
+ before: planned.manifest === null ? null : await readBefore(await manifestPath(root)),
569
+ after: manifestText(planned.next),
570
+ });
571
+ }
572
+ return pairs;
573
+ }
574
+
575
+ /**
576
+ * What `recordLocal` will write, computed without writing (plan 0003 R50).
577
+ * The diff shares this so the pair shows the same object the write applies.
578
+ */
579
+ async function planLocalRecord(root, record, name, repo) {
506
580
  const { manifest, error } = await readManifest(root);
507
- if (error) return { written: false, reason: error };
508
-
509
- // A repo with no manifest gets one here, so this function writes a *promise* on that
510
- // repo's behalf: it decides which gates the repo claims to run. Without a capability
511
- // probe it wrote `format`, `lint`, `test` and `typecheck` into a repository with no
512
- // scripts and no formatter, and `check` then reported four UNRUNNABLE gates. The moment
513
- // hooks route through the manifest - which is the entire point of a gate - every commit
514
- // in that repo is blocked, over gates nobody declared. ADR-017 exists to prevent exactly
515
- // that, and a manifest written by a command that installs one skill was the way round it.
516
- // `init` probes for the same reason; a second writer that does not is a defect waiting
517
- // for the next command that needs a manifest.
518
- const base =
519
- manifest ??
520
- (await buildManifest({
521
- root,
522
- stack: repo.stack,
523
- sha: await provenanceDigest(),
524
- capability: await readCapability(root),
525
- }));
581
+ if (error) return { error };
582
+ const base = manifest ?? (await buildManifest({ root, stack: repo.stack, sha: await provenanceDigest(), capability: await readCapability(root) }));
526
583
  const existing = Array.isArray(base.localSkills) ? base.localSkills : [];
527
584
  const kept = existing.filter((e) => (typeof e === 'string' ? e : e?.name) !== name);
528
- await writeManifest(root, { ...base, localSkills: [...kept, record] });
529
- return { written: true, created: manifest === null };
585
+ return { manifest, next: { ...base, localSkills: [...kept, record] }, created: manifest === null };
586
+ }
587
+
588
+ async function recordLocal(root, record, name, repo, journal) {
589
+ // The manifest this writes is `planned.next`, built by `planLocalRecord`:
590
+ // a repo with no manifest gets one here, which is a promise about which
591
+ // gates the repo claims to run. Without the capability probe inside the
592
+ // plan it wrote `format`, `lint`, `test` and `typecheck` into a repository
593
+ // with no scripts and no formatter, and `check` then reported four
594
+ // UNRUNNABLE gates that blocked every commit (ADR-017). Deriving the object
595
+ // a second way here would be a second answer to what the repo promises.
596
+ const planned = await planLocalRecord(root, record, name, repo);
597
+ if (planned.error) return { written: false, reason: planned.error };
598
+ await writeManifest(root, planned.next, journal);
599
+ return { written: true, created: planned.created };
530
600
  }
@@ -0,0 +1,353 @@
1
+ // `simt slice start` and `simt slice finish`: one tracker row, one branch.
2
+ //
3
+ // A row in a tracker is a promise, and these are the two moments it is acted on.
4
+ // `start` isolates the work: a linked worktree on a branch named for the row, so the
5
+ // main checkout stays free for whatever else is running, and the task brief already on
6
+ // disk at `.simt/brief.md` — the same path the bench's brief arm writes — where an
7
+ // agent picks it up. `finish` closes the loop the way the repository's own hook would:
8
+ // the declared gates run through the shared dispatcher first, because a command that
9
+ // commits for you may not assume a hook is installed to stop it, then the commit
10
+ // carries a Conventional subject built from the row's own title, and only then does
11
+ // the tracker learn the SHA and the test command that closed it — the R31 row
12
+ // machinery, called, never reimplemented.
13
+ //
14
+ // Two orders here are contracts rather than preferences:
15
+ //
16
+ // start: worktree, brief, then row. Everything the row would claim exists before it
17
+ // claims it, so a failed `worktree add` leaves the tracker untouched.
18
+ //
19
+ // finish: gates, commit, row. The row records a SHA that only exists after the
20
+ // commit, and if the row write then fails the message says which half happened —
21
+ // the commit exists, the tracker does not know it — because a silent half-state is
22
+ // how a tracker starts lying.
23
+ //
24
+ // The tracker itself always lives in the MAIN checkout. `start` flips the row there
25
+ // and `finish` records the evidence there, while the worktree carries only the
26
+ // slice's code: editing the same row in both places would make every merge of a
27
+ // finished slice a conflict on the one line that records it.
28
+
29
+ import { execFile } from 'node:child_process';
30
+ import { mkdir, writeFile } from 'node:fs/promises';
31
+ import { dirname, join, relative, resolve } from 'node:path';
32
+ import { promisify } from 'node:util';
33
+
34
+ import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
35
+ import { planDone, planNext } from '../plan/index.mjs';
36
+ import { assembleBrief } from '../brief/assemble.mjs';
37
+ import { renderGateReport, runGates, stagedFiles } from '../hooks/dispatcher.mjs';
38
+ import { readManifest } from '../projection/manifest.mjs';
39
+ import { CLI, MANIFEST_FILE, STATE_DIR } from '../name.mjs';
40
+
41
+ const exec = promisify(execFile);
42
+
43
+ /** The state a started slice moves to, spelled the way the tracker template spells it. */
44
+ const STARTED = 'In progress';
45
+
46
+ /**
47
+ * A Conventional Commits type: one lower-case word, optionally scoped, optionally
48
+ * marked breaking. The scope a `finish` builds is the slice id, so this flag is the
49
+ * subject's only free input — a title cannot say whether a slice was a feat, a fix or
50
+ * a chore, and deriving it from the words would be a guess no reader could argue with.
51
+ */
52
+ const TYPE = /^[a-z]+(?:\([a-z0-9][a-z0-9._-]*\))?!?$/;
53
+
54
+ async function git(root, args) {
55
+ const { stdout } = await exec('git', ['-C', root, ...args], { maxBuffer: 32 << 20 });
56
+ return stdout.trim();
57
+ }
58
+
59
+ /**
60
+ * The checkout that owns the tracker: the main worktree when `root` is a linked one.
61
+ *
62
+ * `--git-common-dir` answers `.git` from the main checkout and `<main>/.git` from a
63
+ * worktree, so resolving it against `root` and dropping the last component gives the
64
+ * right answer in both shapes. A directory that is not a git repository at all falls
65
+ * back to `root`: the plan machinery reads it from wherever it was pointed, and the
66
+ * git commands below will have their own say about whether this is a repository.
67
+ */
68
+ async function mainCheckout(root) {
69
+ try {
70
+ const common = await git(root, ['rev-parse', '--git-common-dir']);
71
+ return dirname(resolve(root, common));
72
+ } catch {
73
+ return root;
74
+ }
75
+ }
76
+
77
+ /** The branch and worktree a slice owns. Both derive from the id: one id, one spelling. */
78
+ function places(main, id) {
79
+ const slug = id.toLowerCase();
80
+ return {
81
+ branch: `slice/${slug}`,
82
+ worktree: join(main, STATE_DIR, 'worktrees', slug),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * The Conventional subject for a finished slice: `<type>(<id>): <title>`.
88
+ *
89
+ * The description is the row's own title, so the message says what the plan said it
90
+ * would do — whitespace collapsed, a trailing period dropped (slice-commit wants an
91
+ * imperative subject with no trailing period), and the id itself as the fallback when
92
+ * a tracker has no title column rather than an empty description after the colon.
93
+ */
94
+ function subjectFor(type, id, title) {
95
+ const description = String(title ?? '').replace(/\s+/g, ' ').trim().replace(/\.$/, '') || id;
96
+ return `${type}(${id.toLowerCase()}): ${description}`;
97
+ }
98
+
99
+ /** Resolve one named slice through `plan next`, which already knows missing and ambiguous. */
100
+ async function resolveSlice(main, id) {
101
+ const found = await planNext(main, id);
102
+ if (found.error) return { error: found.error, remedy: found.remedy ?? `run: ${CLI} plan status` };
103
+ if (!found.next) return { error: found.reason ?? `${id} has no section`, remedy: `run: ${CLI} plan status` };
104
+ return { slice: found.next };
105
+ }
106
+
107
+ export async function sliceCommand(sub, args, flags) {
108
+ const root = flags.root ?? process.cwd();
109
+ if (sub === 'start') {
110
+ const id = args[0];
111
+ if (!id) return fail(`${CLI} slice start needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
112
+ return startSlice(root, id, flags);
113
+ }
114
+ if (sub === 'finish') {
115
+ const id = args[0];
116
+ if (!id) return fail(`${CLI} slice finish needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
117
+ return finishSlice(root, id, flags);
118
+ }
119
+ return fail(`unknown slice subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} slice start <id> | ${CLI} slice finish <id>`);
120
+ }
121
+
122
+ async function startSlice(root, id, flags) {
123
+ const mode = writeMode(flags);
124
+ const main = await mainCheckout(root);
125
+ const resolved = await resolveSlice(main, id);
126
+ if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
127
+ const { title, doc } = resolved.slice;
128
+ const { branch, worktree } = places(main, id);
129
+ const briefPath = join(worktree, STATE_DIR, 'brief.md');
130
+ const task = `${id}: ${String(title ?? '').trim()}`;
131
+ const apply = `run: ${CLI} slice start ${id} --yes`;
132
+
133
+ // The row first, dry, against the main checkout: it is the only part of `start`
134
+ // that can refuse for a reason about the tracker rather than about git, and a
135
+ // preview that discovers it late is a preview that half-described the work.
136
+ const preview = await planDone(main, id, { state: STARTED, dryRun: true });
137
+ if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
138
+
139
+ if (!mode.apply) {
140
+ if (isJson(flags)) {
141
+ emitJson({
142
+ ok: true,
143
+ command: 'slice start',
144
+ root,
145
+ id,
146
+ title,
147
+ dryRun: true,
148
+ branch,
149
+ worktree: relative(main, worktree),
150
+ brief: { path: relative(main, briefPath), bytes: null },
151
+ state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
152
+ remedy: apply,
153
+ });
154
+ return EXIT.OK;
155
+ }
156
+ const lines = [
157
+ `repo ${root}`,
158
+ `slice ${id} ${doc}`,
159
+ `branch ${branch}`,
160
+ `worktree ${relative(main, worktree)}`,
161
+ `brief ${relative(main, briefPath)} (assembled on apply)`,
162
+ `state ${preview.from} -> ${preview.to}`,
163
+ `apply ${apply}`,
164
+ ];
165
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
166
+ return EXIT.OK;
167
+ }
168
+
169
+ // Assemble before anything is created: a packet that cannot be built must leave the
170
+ // repository exactly as it was, rather than a worktree nobody can brief and a row
171
+ // still waiting. `build` defaults on — this is a command a person asked for, not a
172
+ // prompt-submit delivery, so a cold index is built rather than reported.
173
+ const packet = await assembleBrief({ root: main, task });
174
+ if (packet.error) {
175
+ return fail(`the brief for ${id} could not be assembled: ${packet.error}`, EXIT.FAIL, `run: ${CLI} brief ${JSON.stringify(task)}`);
176
+ }
177
+ if (packet.cold) {
178
+ return fail(`the index at ${main} is empty and will not build here`, EXIT.UNSUPPORTED, `run: ${CLI} index`);
179
+ }
180
+
181
+ try {
182
+ await git(main, ['worktree', 'add', '-b', branch, worktree]);
183
+ } catch (error) {
184
+ const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
185
+ return fail(`the worktree could not be created: ${detail}`, EXIT.FAIL, `run: git -C ${main} worktree list`);
186
+ }
187
+
188
+ await mkdir(join(worktree, STATE_DIR), { recursive: true });
189
+ await writeFile(briefPath, packet.packed.text, 'utf8');
190
+
191
+ const row = await planDone(main, id, { state: STARTED, dryRun: false });
192
+ if (row.error) {
193
+ return fail(`${branch} exists with its brief, but the tracker row was not updated: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
194
+ }
195
+
196
+ const bytes = Buffer.byteLength(packet.packed.text);
197
+ if (isJson(flags)) {
198
+ emitJson({
199
+ ok: true,
200
+ command: 'slice start',
201
+ root,
202
+ id,
203
+ title,
204
+ branch,
205
+ worktree: relative(main, worktree),
206
+ brief: { path: relative(main, briefPath), bytes },
207
+ state: { from: row.from, to: row.to, changed: row.changed },
208
+ });
209
+ return EXIT.OK;
210
+ }
211
+ const lines = [
212
+ `repo ${root}`,
213
+ `slice ${id} ${doc}`,
214
+ `branch ${branch}`,
215
+ `worktree ${relative(main, worktree)}`,
216
+ `brief ${relative(main, briefPath)} ${bytes} bytes`,
217
+ `state ${row.from} -> ${row.to}`,
218
+ ];
219
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
220
+ return EXIT.OK;
221
+ }
222
+
223
+ async function finishSlice(root, id, flags) {
224
+ const mode = writeMode(flags);
225
+ const main = await mainCheckout(root);
226
+ const resolved = await resolveSlice(main, id);
227
+ if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
228
+ const { title, doc } = resolved.slice;
229
+
230
+ const type = String(flags.type ?? 'feat');
231
+ if (!TYPE.test(type)) {
232
+ return fail(`${type} is not a Conventional Commits type`, EXIT.USAGE, `example: run: ${CLI} slice finish ${id} --type fix`);
233
+ }
234
+ const subject = subjectFor(type, id, title);
235
+ const test = flags.test ?? null;
236
+
237
+ // A manifest that does not parse fails closed: the gates cannot be read, so nothing
238
+ // may commit through them. No manifest at all is a valid answer — `runGates` turns
239
+ // it into a note and an empty pass, the same way the hook does.
240
+ const manifest = await readManifest(root);
241
+ if (manifest.error) {
242
+ return fail(`${MANIFEST_FILE}: ${manifest.error}`, EXIT.CONFIG, `fix ${MANIFEST_FILE}, or run: ${CLI} init`);
243
+ }
244
+ const declared = manifest.manifest?.gates?.preCommit ?? [];
245
+ const apply = `run: ${CLI} slice finish ${id}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
246
+
247
+ const staged = await stagedFiles(root);
248
+
249
+ if (!mode.apply) {
250
+ const row = await planDone(main, id, { sha: null, test, dryRun: true });
251
+ if (row.error) return fail(row.error, EXIT.USAGE, row.remedy ?? `run: ${CLI} plan status`);
252
+ if (isJson(flags)) {
253
+ emitJson({
254
+ ok: true,
255
+ command: 'slice finish',
256
+ root,
257
+ id,
258
+ dryRun: true,
259
+ subject,
260
+ staged,
261
+ gates: { event: 'pre-commit', declared },
262
+ state: { from: row.from, to: row.to, changed: row.wouldChange },
263
+ remedy: apply,
264
+ });
265
+ return EXIT.OK;
266
+ }
267
+ const lines = [
268
+ `repo ${root}`,
269
+ `slice ${id} ${doc}`,
270
+ `gates pre-commit ${declared.length > 0 ? declared.join(', ') : '(none declared)'} (run on apply)`,
271
+ `staged ${staged.length} file(s)${staged.length === 0 ? ' (finish refuses without one)' : ''}`,
272
+ `subject ${subject}`,
273
+ `state ${row.from} -> ${row.to}`,
274
+ `apply ${apply}`,
275
+ ];
276
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
277
+ return EXIT.OK;
278
+ }
279
+
280
+ if (staged.length === 0) {
281
+ return fail(`${root} has nothing staged to commit`, EXIT.USAGE, `stage what the slice changed, then rerun: ${apply}`);
282
+ }
283
+
284
+ // The gates the hook would run, run here. This command is the one place a commit
285
+ // happens where a hook could not have stopped it, so it runs the same dispatcher
286
+ // over the same event, in the checkout that holds the staged diff.
287
+ const gates = await runGates('pre-commit', { root, manifest: manifest.manifest });
288
+ if (gates.error) return fail(gates.error, EXIT.USAGE);
289
+
290
+ if (gates.failed) {
291
+ const failed = gates.gates.filter((g) => g.status === 'failed').map((g) => g.name).join(', ');
292
+ if (isJson(flags)) {
293
+ emitJson({
294
+ ok: false,
295
+ command: 'slice finish',
296
+ root,
297
+ id,
298
+ exitCode: EXIT.FAIL,
299
+ error: `pre-commit gates failed: ${failed}`,
300
+ remedy: `run: ${CLI} hooks run pre-commit --root ${root}`,
301
+ gates: gates.gates,
302
+ });
303
+ return EXIT.FAIL;
304
+ }
305
+ for (const n of gates.notes ?? []) note(`note ${n}`);
306
+ emit(renderGateReport('pre-commit', declared, gates).join('\n'), { maxBytes: flags.maxBytes ?? 0 });
307
+ return EXIT.FAIL;
308
+ }
309
+
310
+ let sha = null;
311
+ try {
312
+ await git(root, ['commit', '-m', subject]);
313
+ sha = await git(root, ['rev-parse', 'HEAD']);
314
+ } catch (error) {
315
+ const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
316
+ return fail(`the gates passed but the commit failed: ${detail}`, EXIT.FAIL, `run: git -C ${root} commit -m ${JSON.stringify(subject)}`);
317
+ }
318
+
319
+ const row = await planDone(main, id, { sha, test, dryRun: false });
320
+ if (row.error) {
321
+ return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
322
+ }
323
+
324
+ if (isJson(flags)) {
325
+ emitJson({
326
+ ok: true,
327
+ command: 'slice finish',
328
+ root,
329
+ id,
330
+ title,
331
+ commit: { sha, subject },
332
+ gates: gates.gates,
333
+ staged,
334
+ evidence: { recorded: row.evidenceRecorded, sha: row.sha, test: row.test, note: row.evidenceNote },
335
+ row: { file: relative(main, row.file), line: row.line, from: row.from, to: row.to, changed: row.changed },
336
+ });
337
+ return EXIT.OK;
338
+ }
339
+
340
+ for (const n of gates.notes ?? []) note(`note ${n}`);
341
+ const lines = [
342
+ `repo ${root}`,
343
+ `slice ${id} ${doc}`,
344
+ ...renderGateReport('pre-commit', declared, gates),
345
+ `commit ${sha} ${subject}`,
346
+ `state ${row.from} -> ${row.to}`,
347
+ `record ${relative(main, row.file)} line ${row.line} (edited, not staged: commit it with the merge)`,
348
+ ];
349
+ if (row.evidenceRecorded) lines.push(`evidence ${sha}${test ? ` test: ${test}` : ''}`);
350
+ else if (row.evidenceNote) lines.push(`note ${row.evidenceNote}`);
351
+ emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
352
+ return EXIT.OK;
353
+ }
@@ -0,0 +1,62 @@
1
+ // `simt status --line`
2
+ //
3
+ // One line from the last `check`, for shell prompts, tmux and agent status
4
+ // lines: `simt PASS ✓rules ✓hooks 3/3gates`. The line is the gate state *as
5
+ // of the last check* — it reads only `.simt/check.json` plus one
6
+ // `git rev-parse HEAD`, so it answers in milliseconds while `check` itself
7
+ // takes the time projection takes.
8
+ //
9
+ // Bare `status` is a usage error, the way `plan` without a subcommand is: the
10
+ // only mode this command has is `--line`, and running something else by
11
+ // default would invent a second answer to what the status is.
12
+
13
+ import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
14
+ import { CLI } from '../name.mjs';
15
+ import { currentHead, readCheckCache, renderStatusLine } from '../status.mjs';
16
+
17
+ export async function statusCommand(args, flags) {
18
+ const root = flags.root ?? args[0] ?? process.cwd();
19
+ if (flags.line !== true) {
20
+ return fail('status needs --line', EXIT.USAGE, `run: ${CLI} status --line --root ${root}`);
21
+ }
22
+
23
+ const record = await readCheckCache(root);
24
+ if (record === null) {
25
+ // Informational, not a failure: a repository that never ran `check` — or
26
+ // one a writer just invalidated — has no state to report, and a prompt
27
+ // that errored until the first check would train its reader to ignore it.
28
+ if (isJson(flags)) {
29
+ emitJson({ ok: true, command: 'status', root, line: null, cached: false, stale: false });
30
+ return EXIT.OK;
31
+ }
32
+ emit('simt no cache - run check');
33
+ return EXIT.OK;
34
+ }
35
+
36
+ const head = await currentHead(root);
37
+ // Stale when HEAD moved since the check ran. When either side is unknown
38
+ // there is nothing to compare, so the record stands as written.
39
+ const stale = head !== null && record.head !== null && head !== record.head;
40
+ const line = renderStatusLine(record, stale);
41
+ const code = record.blocking === 0 ? EXIT.OK : EXIT.FAIL;
42
+
43
+ if (isJson(flags)) {
44
+ emitJson({
45
+ ok: code === EXIT.OK,
46
+ command: 'status',
47
+ root,
48
+ line,
49
+ cached: true,
50
+ stale,
51
+ rulesOk: record.rulesOk,
52
+ hooksOk: record.hooksOk,
53
+ gatesRun: record.gatesRun,
54
+ gatesTotal: record.gatesTotal,
55
+ blocking: record.blocking,
56
+ warnings: record.warnings,
57
+ });
58
+ return code;
59
+ }
60
+ emit(line);
61
+ return code;
62
+ }