simt-dev 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -1
- package/README.md +46 -17
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -6
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- 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/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +53 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- package/rules/mdc/preview-postflight.mdc +5 -6
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +10 -11
- package/rules/stacks/cloudflare-workers.md +1 -1
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/container.md +2 -0
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +4 -3
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/react-native.md +3 -5
- package/rules/stacks/react-web.md +9 -9
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +238 -15
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/policy.mjs +81 -5
- package/src/agenthooks/prompt.mjs +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shell.mjs +523 -0
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +62 -9
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +133 -17
- package/src/commands/bench.mjs +2 -1
- package/src/commands/ci.mjs +28 -3
- package/src/commands/compare.mjs +8 -7
- package/src/commands/completions.mjs +27 -0
- package/src/commands/decide.mjs +20 -6
- package/src/commands/doctor.mjs +367 -0
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/explain.mjs +52 -0
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +96 -26
- package/src/commands/hooks.mjs +258 -67
- package/src/commands/ignore.mjs +152 -0
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +69 -102
- package/src/commands/init.mjs +239 -37
- package/src/commands/lease.mjs +304 -0
- package/src/commands/lessons.mjs +77 -9
- package/src/commands/map.mjs +33 -18
- 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 +17 -2
- package/src/commands/prbody.mjs +45 -0
- package/src/commands/repoindex.mjs +4 -0
- package/src/commands/rules.mjs +219 -105
- package/src/commands/shared.mjs +36 -5
- package/src/commands/skills.mjs +329 -206
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +112 -30
- package/src/commands/slice.mjs +712 -0
- package/src/commands/status.mjs +62 -0
- package/src/commands/sync.mjs +146 -36
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +114 -0
- package/src/commands/upgrade.mjs +103 -9
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +196 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +254 -0
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +270 -0
- package/src/federation/config.mjs +44 -0
- package/src/graph/load.mjs +52 -24
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +441 -9
- package/src/hooks/gates.mjs +14 -3
- package/src/hooks/resolve.mjs +23 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- package/src/ignore.mjs +133 -0
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +372 -55
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +545 -0
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +195 -0
- package/src/ledger/index.mjs +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +86 -13
- package/src/mcp/tools.mjs +340 -264
- package/src/memory/decide.mjs +98 -38
- package/src/memory/lessons.mjs +180 -33
- package/src/name.mjs +9 -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 +37 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +33 -8
- package/src/prbody.mjs +253 -0
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +21 -14
- package/src/projection/manifest.mjs +82 -21
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/shared.mjs +4 -1
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +374 -0
- package/src/state.mjs +76 -0
- package/src/status.mjs +157 -0
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/output/index.mjs
CHANGED
|
@@ -144,20 +144,90 @@ export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
|
|
|
144
144
|
* the only consumer is a person: the compact document saves them nothing and costs them
|
|
145
145
|
* the diff they were about to read.
|
|
146
146
|
*/
|
|
147
|
+
function withSchemaVersion(value) {
|
|
148
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) && value.schemaVersion === undefined
|
|
149
|
+
? { schemaVersion: SCHEMA_VERSION, ...value }
|
|
150
|
+
: value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function stringifyJson(value, pretty) {
|
|
154
|
+
return pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Shrink a payload to a byte ceiling by emptying its longest arrays first.
|
|
159
|
+
*
|
|
160
|
+
* Byte-cutting JSON produces a document that parses or exits non-zero, never both —
|
|
161
|
+
* `why --json --max-bytes 120` used to exit 0 with half a token on stdout (Q10). An
|
|
162
|
+
* importer owns nothing here: the trimmed arrays are named in `truncated`, so a reader
|
|
163
|
+
* knows exactly which list lost how many items, and the document still parses.
|
|
164
|
+
*
|
|
165
|
+
* Returns the fitted payload, or null when no array-trimming fits (the caller then
|
|
166
|
+
* answers with the `MAX_BYTES` error envelope instead).
|
|
167
|
+
*/
|
|
168
|
+
function fitJsonArrays(payload, maxBytes, pretty) {
|
|
169
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
170
|
+
const slots = [];
|
|
171
|
+
const walk = (node, path) => {
|
|
172
|
+
if (Array.isArray(node)) {
|
|
173
|
+
if (node.length > 0) slots.push({ node, path, length: node.length });
|
|
174
|
+
node.forEach((item, i) => walk(item, `${path}[${i}]`));
|
|
175
|
+
} else if (node !== null && typeof node === 'object') {
|
|
176
|
+
for (const key of Object.keys(node)) walk(node[key], path === '' ? key : `${path}.${key}`);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
walk(payload, '');
|
|
180
|
+
if (slots.length === 0) return null;
|
|
181
|
+
slots.sort((a, b) => b.length - a.length);
|
|
182
|
+
const candidate = structuredClone(payload);
|
|
183
|
+
const trimmed = [];
|
|
184
|
+
const sizeOf = (extra) =>
|
|
185
|
+
Buffer.byteLength(stringifyJson(extra ? { ...candidate, truncated: extra } : candidate, pretty));
|
|
186
|
+
const segmentsOf = (path) =>
|
|
187
|
+
path.split('.').flatMap((part) => {
|
|
188
|
+
const m = /^(.*?)\[(\d+)\]$/.exec(part);
|
|
189
|
+
return m ? [m[1], Number(m[2])] : [part];
|
|
190
|
+
});
|
|
191
|
+
for (const slot of slots) {
|
|
192
|
+
const at = segmentsOf(slot.path);
|
|
193
|
+
let target = candidate;
|
|
194
|
+
for (let i = 0; i < at.length - 1; i += 1) target = target[at[i]];
|
|
195
|
+
if (target === null || typeof target !== 'object') continue;
|
|
196
|
+
target[at[at.length - 1]] = [];
|
|
197
|
+
trimmed.push({ field: slot.path, omitted: slot.length });
|
|
198
|
+
const extra = trimmed.length === 1
|
|
199
|
+
? { field: trimmed[0].field, omitted: trimmed[0].omitted, limit: maxBytes }
|
|
200
|
+
: { fields: trimmed, limit: maxBytes };
|
|
201
|
+
if (sizeOf(extra) <= maxBytes) return { ...candidate, truncated: extra };
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
147
206
|
export function emitJson(value, { stream = process.stdout, pretty = prettyDefault, maxBytes = 0 } = {}) {
|
|
148
|
-
const payload =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
// The same ceiling and the same announcement `emit` applies: a reader who asked for a
|
|
154
|
-
// bounded answer gets a bounded answer and is told how much is missing, rather than a
|
|
155
|
-
// half-parsed document it believes is whole.
|
|
207
|
+
const payload = withSchemaVersion(value);
|
|
208
|
+
const text = `${stringifyJson(payload, pretty)}\n`;
|
|
209
|
+
// JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
|
|
210
|
+
// true. Arrays shrink with a `truncated` record naming what lost how much, and a
|
|
211
|
+
// document no trimming can fit becomes a `MAX_BYTES` error envelope (Q10, ADR-105).
|
|
156
212
|
if (maxBytes > 0 && Buffer.byteLength(text) > maxBytes) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
const fitted = fitJsonArrays(payload, maxBytes, pretty);
|
|
214
|
+
if (fitted !== null) {
|
|
215
|
+
stream.write(`${stringifyJson(fitted, pretty)}\n`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
const envelope = withSchemaVersion({
|
|
219
|
+
...(payload !== null && typeof payload === 'object' && !Array.isArray(payload) && typeof payload.command === 'string'
|
|
220
|
+
? { command: payload.command }
|
|
221
|
+
: {}),
|
|
222
|
+
ok: false,
|
|
223
|
+
error: {
|
|
224
|
+
code: 'MAX_BYTES',
|
|
225
|
+
message: `output exceeded the --max-bytes ceiling of ${maxBytes} bytes`,
|
|
226
|
+
remedy: 'rerun with --max-bytes 0 for the whole document',
|
|
227
|
+
},
|
|
228
|
+
truncated: { omitted: Buffer.byteLength(text) - maxBytes, limit: maxBytes },
|
|
229
|
+
});
|
|
230
|
+
stream.write(`${stringifyJson(envelope, pretty)}\n`);
|
|
161
231
|
return false;
|
|
162
232
|
}
|
|
163
233
|
stream.write(text);
|
package/src/package-version.mjs
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
// must match: npm pins the node_modules copy for us, but nothing pins a binary, so
|
|
15
15
|
// the gate compares the running version against the pin and refuses on a mismatch.
|
|
16
16
|
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
17
18
|
import { readFileSync } from 'node:fs';
|
|
18
19
|
import { stat } from 'node:fs/promises';
|
|
19
|
-
import { join } from 'node:path';
|
|
20
|
+
import { dirname, join } from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
20
22
|
|
|
21
23
|
import { MANIFEST_FILE } from './name.mjs';
|
|
22
24
|
|
|
@@ -38,6 +40,52 @@ export function packageVersion() {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/** The directory this package is installed in, whatever resolved the import. */
|
|
44
|
+
function packageRootDir() {
|
|
45
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The short commit this checkout is at, or null outside a git checkout (an
|
|
50
|
+
* installed tarball or wheel has no `.git`). Read-only and best-effort: a
|
|
51
|
+
* version string must never fail because the VCS is absent (Q05).
|
|
52
|
+
*/
|
|
53
|
+
export function checkoutSha() {
|
|
54
|
+
try {
|
|
55
|
+
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
56
|
+
cwd: packageRootDir(),
|
|
57
|
+
encoding: 'utf8',
|
|
58
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
59
|
+
}).trim();
|
|
60
|
+
return /^[0-9a-f]+$/.test(sha) ? sha : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What `--version` prints: the package version, plus the checkout SHA when run
|
|
68
|
+
* from a source tree past its tag. A `main` 26 commits past v0.6.2 reporting
|
|
69
|
+
* bare `0.6.2` is the drift Q05 closes: the number alone claims a release the
|
|
70
|
+
* bytes are not (Q05).
|
|
71
|
+
*/
|
|
72
|
+
export function describeVersion() {
|
|
73
|
+
const version = packageVersion();
|
|
74
|
+
const sha = checkoutSha();
|
|
75
|
+
if (!sha) return `simt-dev ${version}`;
|
|
76
|
+
try {
|
|
77
|
+
const tag = execFileSync('git', ['describe', '--tags', '--exact-match'], {
|
|
78
|
+
cwd: packageRootDir(),
|
|
79
|
+
encoding: 'utf8',
|
|
80
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
81
|
+
}).trim();
|
|
82
|
+
if (tag === `v${version}`) return `simt-dev ${version}`;
|
|
83
|
+
} catch {
|
|
84
|
+
// Not exactly on the release tag: fall through to the SHA suffix.
|
|
85
|
+
}
|
|
86
|
+
return `simt-dev ${version} (${sha})`;
|
|
87
|
+
}
|
|
88
|
+
|
|
41
89
|
/**
|
|
42
90
|
* Whether a repository has a package.json, i.e. whether it pins simt-dev as a
|
|
43
91
|
* devDependency (npm) or must download the release binary (ADR-098). Async because the render
|
package/src/packs/manage.mjs
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// copied in and pinned by digest, never fetched at render time.
|
|
15
15
|
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
|
-
import { mkdir, readdir, readFile, stat
|
|
17
|
+
import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
18
19
|
import { createHash } from 'node:crypto';
|
|
19
20
|
import { basename, dirname, join } from 'node:path';
|
|
20
21
|
import { promisify } from 'node:util';
|
|
@@ -545,8 +546,14 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
545
546
|
return { pack: linted.pack, ok: true, published: true, skipped: false, planned, findings };
|
|
546
547
|
}
|
|
547
548
|
|
|
548
|
-
/**
|
|
549
|
-
|
|
549
|
+
/**
|
|
550
|
+
* Write scaffold files into a directory that must not already exist.
|
|
551
|
+
*
|
|
552
|
+
* `root` is the repository the journal set belongs to — usually the scaffold's
|
|
553
|
+
* own parent, so `undo` runs where the files live. Recorded (R49) because a
|
|
554
|
+
* scaffold is repo files like any other write.
|
|
555
|
+
*/
|
|
556
|
+
export async function writeScaffold(dir, files, root = dirname(dir)) {
|
|
550
557
|
try {
|
|
551
558
|
await stat(dir);
|
|
552
559
|
throw new Error(
|
|
@@ -555,9 +562,11 @@ export async function writeScaffold(dir, files) {
|
|
|
555
562
|
} catch (error) {
|
|
556
563
|
if (error?.message?.startsWith('refusing to scaffold')) throw error;
|
|
557
564
|
}
|
|
565
|
+
const journal = [];
|
|
558
566
|
for (const file of files) {
|
|
559
567
|
const abs = join(dir, file.path);
|
|
560
568
|
await mkdir(dirname(abs), { recursive: true });
|
|
561
|
-
await
|
|
569
|
+
await journaledWriteFile(root, journal, abs, file.text);
|
|
562
570
|
}
|
|
571
|
+
await commitJournal(root, 'pack new', journal);
|
|
563
572
|
}
|
package/src/plan/index.mjs
CHANGED
|
@@ -20,10 +20,11 @@
|
|
|
20
20
|
// the file was read. Status and dependency checks never touch it: a 1 MB
|
|
21
21
|
// section that belongs to a later slice is not copied into the index.
|
|
22
22
|
|
|
23
|
-
import { readdir, readFile
|
|
23
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
|
|
26
26
|
import { CLI } from '../name.mjs';
|
|
27
|
+
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
27
28
|
import { loadFormat } from './template.mjs';
|
|
28
29
|
|
|
29
30
|
function clean(cell) {
|
|
@@ -100,10 +101,16 @@ function sliceKey(path, id) {
|
|
|
100
101
|
* empty cell is the pipe that closes it. A further empty cell is real: the
|
|
101
102
|
* evidence column is often blank, and stripping every trailing empty would
|
|
102
103
|
* delete it and shift the state into the wrong column.
|
|
104
|
+
*
|
|
105
|
+
* A pipe inside a code span is content, not a separator: an evidence cell may
|
|
106
|
+
* carry a command line with a pipe in it, and splitting there shifts every
|
|
107
|
+
* later cell left by one (review 0003 Q25). Backticks toggle the span; a
|
|
108
|
+
* `\|` escape is content in either mode.
|
|
103
109
|
*/
|
|
104
110
|
function splitRow(line) {
|
|
105
111
|
const cells = [];
|
|
106
112
|
let current = '';
|
|
113
|
+
let inCode = false;
|
|
107
114
|
for (let i = 0; i < line.length; i += 1) {
|
|
108
115
|
const char = line[i];
|
|
109
116
|
if (char === '\\' && i + 1 < line.length) {
|
|
@@ -111,7 +118,12 @@ function splitRow(line) {
|
|
|
111
118
|
i += 1;
|
|
112
119
|
continue;
|
|
113
120
|
}
|
|
114
|
-
if (char === '
|
|
121
|
+
if (char === '`') {
|
|
122
|
+
inCode = !inCode;
|
|
123
|
+
current += char;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (char === '|' && !inCode) {
|
|
115
127
|
cells.push(current);
|
|
116
128
|
current = '';
|
|
117
129
|
continue;
|
|
@@ -596,9 +608,16 @@ export async function planDone(root, id, { sha = null, test = null, state = null
|
|
|
596
608
|
return { root, id, error: `${id} is no longer on line ${slice.line} of ${slice.docName}`, remedy: `run: ${CLI} plan status` };
|
|
597
609
|
}
|
|
598
610
|
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
611
|
+
const original = found.lines[found.index];
|
|
612
|
+
const cells = splitRow(original);
|
|
613
|
+
if (cells.length !== slice.columnCount) {
|
|
614
|
+
return {
|
|
615
|
+
root,
|
|
616
|
+
id,
|
|
617
|
+
error: `the row for ${id} has ${cells.length} cells; the table declares ${slice.columnCount}`,
|
|
618
|
+
remedy: `fix the row in ${slice.docName} — an unescaped pipe splits it — and retry`,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
602
621
|
const from = cells[slice.stateIdx] ?? '';
|
|
603
622
|
cells[slice.stateIdx] = replaceState(from, display);
|
|
604
623
|
|
|
@@ -613,14 +632,18 @@ export async function planDone(root, id, { sha = null, test = null, state = null
|
|
|
613
632
|
evidenceNote = 'no evidence column in this tracker; the sha was not recorded';
|
|
614
633
|
}
|
|
615
634
|
|
|
616
|
-
|
|
617
|
-
|
|
635
|
+
// The rebuilt line keeps the original's ending: a CRLF file must not gain
|
|
636
|
+
// one LF-terminated line among its CRLF lines (review 0003 Q25).
|
|
637
|
+
const eol = original.endsWith('\r') ? '\r' : '';
|
|
638
|
+
const rebuilt = `| ${cells.join(' | ')} |${eol}`;
|
|
618
639
|
const changedCells = splitRow(original).join('\0') !== cells.join('\0');
|
|
619
640
|
const newLine = changedCells ? rebuilt : original;
|
|
620
641
|
const wouldChange = newLine !== original;
|
|
621
642
|
if (wouldChange && !dryRun) {
|
|
622
643
|
found.lines[found.index] = newLine;
|
|
623
|
-
|
|
644
|
+
const journal = [];
|
|
645
|
+
await journaledWriteFile(root, journal, slice.docPath, found.lines.join('\n'));
|
|
646
|
+
await commitJournal(root, 'plan', journal);
|
|
624
647
|
}
|
|
625
648
|
|
|
626
649
|
const apply = `run: ${CLI} plan done ${id}${sha ? ` ${sha}` : ''}${display === format.doneWrite ? '' : ` --state ${JSON.stringify(display)}`}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
|
|
@@ -632,6 +655,12 @@ export async function planDone(root, id, { sha = null, test = null, state = null
|
|
|
632
655
|
line: found.index + 1,
|
|
633
656
|
from,
|
|
634
657
|
to: cells[slice.stateIdx],
|
|
658
|
+
// The `--diff` sides for the one changed row (plan 0003 R50). The full file
|
|
659
|
+
// is not carried: one table row is the whole write, and a context-less hunk
|
|
660
|
+
// over the row says exactly what changed. Captured pre-write, so the diff
|
|
661
|
+
// is true in preview and in apply alike.
|
|
662
|
+
beforeLine: original,
|
|
663
|
+
afterLine: newLine,
|
|
635
664
|
state: format.readState(display),
|
|
636
665
|
wouldChange,
|
|
637
666
|
changed: wouldChange && !dryRun,
|
package/src/plan/template.mjs
CHANGED
|
@@ -23,7 +23,7 @@ const KNOWN = new Set(['$comment', 'name', 'directories', 'id', 'columns', 'stat
|
|
|
23
23
|
const ID_KEYS = new Set(['pattern', 'headers']);
|
|
24
24
|
const COLUMN_KEYS = new Set(['state', 'depends', 'title', 'evidence']);
|
|
25
25
|
const DIRECTORY_KEYS = new Set(['kind', 'word', 'path']);
|
|
26
|
-
const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies']);
|
|
26
|
+
const STATE_KEYS = new Set(['id', 'write', 'read', 'satisfies', 'role']);
|
|
27
27
|
const DEPENDENCY_KEYS = new Set(['empty', 'separators', 'notes', 'group']);
|
|
28
28
|
const WAVE_KEYS = new Set(['label', 'members']);
|
|
29
29
|
const SECTION_KEYS = new Set(['depth']);
|
|
@@ -148,6 +148,7 @@ function compileStates(source, value) {
|
|
|
148
148
|
const write = new Map();
|
|
149
149
|
const known = new Set();
|
|
150
150
|
const satisfies = new Set();
|
|
151
|
+
const roles = new Map();
|
|
151
152
|
const order = [];
|
|
152
153
|
const stateNames = [];
|
|
153
154
|
for (let i = 0; i < value.length; i += 1) {
|
|
@@ -162,6 +163,13 @@ function compileStates(source, value) {
|
|
|
162
163
|
}
|
|
163
164
|
if (!Array.isArray(entry.read) || entry.read.length === 0) return bad(source, `${where}.read must list the words that mean this state`);
|
|
164
165
|
if (known.has(entry.id)) return bad(source, `state ${entry.id} is listed twice`);
|
|
166
|
+
if (entry.role !== undefined && (typeof entry.role !== 'string' || !/^[a-z][a-z0-9-]*$/.test(entry.role))) {
|
|
167
|
+
return bad(source, `${where}.role must be a lower-case word`);
|
|
168
|
+
}
|
|
169
|
+
if (typeof entry.role === 'string') {
|
|
170
|
+
if (roles.has(entry.role)) return bad(source, `role ${entry.role} is claimed twice`);
|
|
171
|
+
roles.set(entry.role, entry.write.trim());
|
|
172
|
+
}
|
|
165
173
|
const display = entry.write.trim();
|
|
166
174
|
const aliases = new Set([entry.id, display.toLowerCase()]);
|
|
167
175
|
for (const alias of entry.read) {
|
|
@@ -182,7 +190,7 @@ function compileStates(source, value) {
|
|
|
182
190
|
else if (entry.satisfies != null && entry.satisfies !== false) return bad(source, `${where}.satisfies must be true or false`);
|
|
183
191
|
}
|
|
184
192
|
if (satisfies.size === 0) return bad(source, 'one state must have satisfies true, or nothing can ever be ready');
|
|
185
|
-
return { read, write, known, satisfies, order, stateNames };
|
|
193
|
+
return { read, write, known, satisfies, roles, order, stateNames };
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
function compile(body, source, root) {
|
|
@@ -262,7 +270,7 @@ function compile(body, source, root) {
|
|
|
262
270
|
|
|
263
271
|
const byWord = new Map(directories.directories.map((dir) => [dir.word, dir.kind]));
|
|
264
272
|
const doneWrite = body.states.find((entry) => entry.satisfies === true).write.trim();
|
|
265
|
-
const { read, write } = states;
|
|
273
|
+
const { read, write, roles } = states;
|
|
266
274
|
|
|
267
275
|
return {
|
|
268
276
|
format: {
|
|
@@ -273,6 +281,12 @@ function compile(body, source, root) {
|
|
|
273
281
|
order: states.order,
|
|
274
282
|
ready,
|
|
275
283
|
doneWrite,
|
|
284
|
+
// The display a command moves a row to when work on it starts: the state
|
|
285
|
+
// carrying the `started` role, or null when no state claims it (review
|
|
286
|
+
// 0003 Q23). A null here is not an error — the caller falls back to the
|
|
287
|
+
// in-progress spelling it can read, and `plan done` fails closed on a
|
|
288
|
+
// word the template never defined.
|
|
289
|
+
startedWrite: roles.get('started') ?? null,
|
|
276
290
|
stateNames: states.stateNames,
|
|
277
291
|
satisfies: states.satisfies,
|
|
278
292
|
known: states.known,
|
|
@@ -292,9 +306,17 @@ function compile(body, source, root) {
|
|
|
292
306
|
headingRe: new RegExp(`^(#{1,${depth}})\\s+(.*\\S)\\s*$`),
|
|
293
307
|
readState(raw) {
|
|
294
308
|
const plain = String(raw ?? '').replace(/\*\*/g, '').trim().toLowerCase();
|
|
309
|
+
if (plain === '') return 'unknown';
|
|
295
310
|
if (read.has(plain)) return read.get(plain);
|
|
296
|
-
|
|
297
|
-
|
|
311
|
+
// A cell may carry a note after the state — "In progress (since Monday)".
|
|
312
|
+
// The state is the longest alias that prefixes the text: "in progress"
|
|
313
|
+
// must win over a shorter alias it starts, and a word no state claims
|
|
314
|
+
// is unknown, not a state (review 0003 Q25).
|
|
315
|
+
let best = '';
|
|
316
|
+
for (const alias of read.keys()) {
|
|
317
|
+
if (alias.length > best.length && plain.startsWith(alias)) best = alias;
|
|
318
|
+
}
|
|
319
|
+
return best !== '' ? read.get(best) : 'unknown';
|
|
298
320
|
},
|
|
299
321
|
canonical(input) {
|
|
300
322
|
return write.get(String(input ?? '').trim().toLowerCase()) ?? null;
|
package/src/plugins.mjs
CHANGED
|
@@ -24,13 +24,24 @@
|
|
|
24
24
|
// leaves the machine it was built on. The command says so rather than pretending.
|
|
25
25
|
|
|
26
26
|
import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
27
|
-
import { basename, join } from 'node:path';
|
|
27
|
+
import { basename, dirname, join } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { readBefore } from './diff.mjs';
|
|
28
30
|
import { hookDocument } from './agenthooks/render.mjs';
|
|
29
31
|
import { desiredRegion } from './projection/rules.mjs';
|
|
30
32
|
import { PACKAGE_NAME, BIN_REL } from './name.mjs';
|
|
31
33
|
|
|
32
34
|
/** The kinds `simt plugin` builds, in the order the help line names them. */
|
|
33
|
-
export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor'];
|
|
35
|
+
export const PLUGIN_KINDS = ['claude', 'gemini', 'cursor', 'opencode'];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The package this code ships in: the runtime a generated package copies. Resolved
|
|
39
|
+
* from the module URL, not from the consumer's cwd, so `plugin` built with
|
|
40
|
+
* `--root <consumer>` still ships this tool rather than the consumer's files (Q03).
|
|
41
|
+
*/
|
|
42
|
+
function packageRoot() {
|
|
43
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
44
|
+
}
|
|
34
45
|
|
|
35
46
|
/** Where the runtime copy lands inside a package, relative to the package root. */
|
|
36
47
|
function runtimeDir(kind) {
|
|
@@ -60,7 +71,11 @@ function rootFlag(kind) {
|
|
|
60
71
|
|
|
61
72
|
/** Where the hooks document lands inside a package, relative to the package root. */
|
|
62
73
|
function hooksPath(kind) {
|
|
63
|
-
|
|
74
|
+
if (kind === 'cursor') return join('.cursor', 'hooks.json');
|
|
75
|
+
// OpenCode loads a local plugin directory's root `server` file as the plugin, so the
|
|
76
|
+
// generated document is that file at the root rather than a hooks config to merge.
|
|
77
|
+
if (kind === 'opencode') return 'server.js';
|
|
78
|
+
return join('hooks', 'hooks.json');
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
@@ -71,7 +86,7 @@ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
|
71
86
|
* it is many files; this returns only the handful that are authored here.
|
|
72
87
|
*/
|
|
73
88
|
async function packageFiles(kind, { root = process.cwd(), out } = {}) {
|
|
74
|
-
const pkg = JSON.parse(await readFile(join(
|
|
89
|
+
const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
|
|
75
90
|
const command = `${entryPoint(kind)} agent-hook pre-tool-use --harness ${kind}${rootFlag(kind)}`;
|
|
76
91
|
const files = [];
|
|
77
92
|
|
|
@@ -128,12 +143,21 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
|
|
|
128
143
|
throw new Error(`unknown package kind \`${kind}\`; one of: ${PLUGIN_KINDS.join(', ')}`);
|
|
129
144
|
}
|
|
130
145
|
|
|
131
|
-
const pkg = JSON.parse(await readFile(join(
|
|
146
|
+
const pkg = JSON.parse(await readFile(join(packageRoot(), 'package.json'), 'utf8'));
|
|
132
147
|
const files = await packageFiles(kind, { root, out });
|
|
133
148
|
const notes = [
|
|
134
149
|
'the skill registry is workspace data (ADR-061) and is not shipped; run `simt skills index` in the profile to list what a workspace would offer',
|
|
135
150
|
];
|
|
136
151
|
|
|
152
|
+
// The `--diff` before-sides, read before anything is written so the diff is
|
|
153
|
+
// true in preview and in apply alike (R50). Always captured: the files are a
|
|
154
|
+
// handful of small generated configs. The runtime copy below is excluded — it
|
|
155
|
+
// is a mechanical copy of this tool's own sources, not planned content, and
|
|
156
|
+
// diffing it would reprint the tool into the review.
|
|
157
|
+
for (const f of files) {
|
|
158
|
+
f.before = await readBefore(join(out, f.path));
|
|
159
|
+
}
|
|
160
|
+
|
|
137
161
|
if (apply) {
|
|
138
162
|
const runtimeTarget = join(out, runtimeDir(kind));
|
|
139
163
|
await mkdir(runtimeTarget, { recursive: true });
|
|
@@ -142,10 +166,11 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
|
|
|
142
166
|
// dies on the first MCP call: the server reads templates/ and rules/, which a
|
|
143
167
|
// bin+src-only copy leaves behind. The `files` field is the definition of "a working
|
|
144
168
|
// copy of this tool", so it is the set a package ships.
|
|
169
|
+
const runtimeRoot = packageRoot();
|
|
145
170
|
for (const part of pkg.files) {
|
|
146
|
-
await cp(join(
|
|
171
|
+
await cp(join(runtimeRoot, part), join(runtimeTarget, part), { recursive: true });
|
|
147
172
|
}
|
|
148
|
-
await cp(join(
|
|
173
|
+
await cp(join(runtimeRoot, 'package.json'), join(runtimeTarget, 'package.json'));
|
|
149
174
|
for (const f of files) {
|
|
150
175
|
const target = join(out, f.path);
|
|
151
176
|
await mkdir(join(target, '..'), { recursive: true });
|
|
@@ -158,7 +183,7 @@ export async function buildPackage(kind, { root = process.cwd(), out, apply = fa
|
|
|
158
183
|
out,
|
|
159
184
|
apply,
|
|
160
185
|
runtime: { target: runtimeDir(kind), parts: [...pkg.files, 'package.json'] },
|
|
161
|
-
files
|
|
186
|
+
files,
|
|
162
187
|
notes,
|
|
163
188
|
};
|
|
164
189
|
}
|