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.
- package/CHANGELOG.md +71 -0
- package/README.md +44 -15
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-098-the-fourth-channel-is-one-file-and-it-needs-no-toolchain.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +79 -0
- package/docs/decisions/ADR-100-a-staged-diff-is-the-last-gate-before-history-and-it-runs-inside-the-tool.md +94 -0
- package/docs/decisions/ADR-101-a-file-lease-belongs-to-one-session-and-the-pre-edit-hook-refuses-another.md +95 -0
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +49 -0
- package/docs/decisions/ADR-103-co-projected-duplicates-merge-down.md +74 -0
- package/docs/decisions/ADR-104-the-pre-tool-use-policy-reads-the-command-the-way-a-shell-reads-it.md +76 -0
- package/docs/decisions/ADR-105-the-json-ceiling-shrinks-arrays-and-never-cuts-bytes.md +44 -0
- package/docs/decisions/README.md +44 -0
- package/package.json +1 -1
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/stacks/android.md +7 -8
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/jvm.md +1 -1
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/src/agenthooks/decide.mjs +101 -10
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/shell.mjs +523 -0
- package/src/argv.mjs +54 -9
- package/src/ci/workflow.mjs +7 -5
- package/src/commands/agenthook.mjs +25 -3
- package/src/commands/ci.mjs +28 -3
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +14 -1
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/explain.mjs +52 -0
- package/src/commands/help.mjs +66 -17
- package/src/commands/hooks.mjs +208 -60
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/index.mjs +54 -100
- package/src/commands/init.mjs +82 -12
- package/src/commands/lease.mjs +136 -0
- package/src/commands/lessons.mjs +12 -0
- package/src/commands/mcp.mjs +2 -2
- package/src/commands/pack.mjs +9 -0
- package/src/commands/plan.mjs +13 -1
- package/src/commands/plugin.mjs +16 -1
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +3 -0
- package/src/commands/rules.mjs +145 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +293 -205
- package/src/commands/skillsinstall.mjs +100 -30
- package/src/commands/slice.mjs +353 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +110 -15
- package/src/commands/undo.mjs +106 -0
- package/src/commands/upgrade.mjs +70 -8
- package/src/completions.mjs +191 -0
- package/src/diff.mjs +254 -0
- package/src/explain.mjs +258 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +14 -5
- package/src/hooks/dispatcher.mjs +110 -0
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +14 -0
- package/src/ignore.mjs +133 -0
- package/src/index/persist.mjs +66 -9
- package/src/journal.mjs +326 -0
- package/src/lease.mjs +113 -0
- package/src/mcp/server.mjs +15 -1
- package/src/mcp/tools.mjs +296 -256
- package/src/memory/decide.mjs +6 -1
- package/src/memory/lessons.mjs +12 -0
- package/src/output/index.mjs +82 -12
- package/src/package-version.mjs +49 -1
- package/src/packs/manage.mjs +13 -4
- package/src/plan/index.mjs +6 -0
- package/src/plugins.mjs +27 -6
- package/src/prbody.mjs +251 -0
- package/src/projection/manifest.mjs +13 -2
- package/src/projection/shared.mjs +4 -1
- package/src/secrets.mjs +231 -0
- 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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
529
|
-
|
|
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
|
+
}
|