simt-dev 0.7.0 → 0.8.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 +191 -870
- package/README.md +188 -213
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-014-workspace-is-configuration.md +2 -2
- package/docs/decisions/ADR-025-a-baseline-is-a-region-in-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-026-a-config-without-a-region-is-a-file-the-repo-owns.md +1 -1
- package/docs/decisions/ADR-027-the-one-gitattributes-line-that-is-neither-dangerous-nor-empty.md +1 -1
- package/docs/decisions/ADR-028-a-base-file-is-a-floor-so-the-disagreement-it-cannot-repair-becomes-a-check.md +1 -1
- package/docs/decisions/ADR-029-the-pins-a-repository-declares-are-its-own.md +1 -1
- package/docs/decisions/ADR-030-what-a-plan-does-not-ship-is-a-decision-with-a-record.md +1 -1
- package/docs/decisions/ADR-048-a-baseline-carries-only-keys-a-reader-honours.md +1 -1
- package/docs/decisions/ADR-082-the-tracker-override-is-a-manifest-key.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-117-the-darwin-release-builds-on-supported-macos-15-runners-and-a-re-run-is-idempotent.md +81 -0
- package/docs/decisions/ADR-118-the-bench-corpus-is-keyed-on-history-is-append-only-and-is-portable-as-a-sealed-document.md +91 -0
- package/docs/decisions/ADR-119-the-environment-variable-points-into-a-workspace-the-repo-declared.md +90 -0
- package/docs/decisions/ADR-120-a-pre-push-whose-tree-is-already-proven-green-skips-the-test-gate-on-that-evidence.md +96 -0
- package/docs/decisions/ADR-122-the-shims-move-into-the-state-directory-and-the-legacy-directory-removes-itself.md +94 -0
- package/docs/decisions/ADR-123-a-surplus-positional-is-not-refused-because-arity-is-not-in-the-source.md +57 -0
- package/docs/decisions/ADR-124-the-manifest-has-a-schema-in-the-tool-and-its-unknown-keys-stay-the-repositorys.md +64 -0
- package/docs/decisions/ADR-125-a-shared-context-belongs-to-a-read-and-a-writer-reads-directly.md +65 -0
- package/docs/decisions/ADR-126-the-mcp-surface-stays-a-reader-so-there-is-no-write-path-to-build.md +68 -0
- package/docs/decisions/ADR-127-the-mcp-tool-list-is-written-by-hand-and-roots-is-not-asked-for.md +65 -0
- package/docs/decisions/ADR-128-a-research-plan-is-a-plan-and-the-tracker-reads-it.md +42 -0
- package/ecosystems/bun.json +6 -0
- package/ecosystems/cargo.json +25 -0
- package/ecosystems/composer.json +6 -0
- package/ecosystems/dart.json +24 -0
- package/ecosystems/deno.json +21 -0
- package/ecosystems/devcontainers.json +6 -0
- package/ecosystems/docker-compose.json +6 -0
- package/ecosystems/docker.json +6 -0
- package/ecosystems/dotnet.json +25 -0
- package/ecosystems/elixir.json +23 -0
- package/ecosystems/gitsubmodule.json +6 -0
- package/ecosystems/go.json +25 -0
- package/ecosystems/gradle.json +13 -0
- package/ecosystems/maven.json +23 -0
- package/ecosystems/node.json +25 -0
- package/ecosystems/python.json +20 -0
- package/ecosystems/ruby.json +23 -0
- package/ecosystems/swift.json +25 -0
- package/ecosystems/terraform.json +6 -0
- package/ecosystems/uv.json +6 -0
- package/harnesses/antigravity.json +162 -0
- package/harnesses/claude.json +206 -0
- package/harnesses/codex.json +136 -0
- package/harnesses/copilot.json +88 -0
- package/harnesses/cursor.json +140 -0
- package/harnesses/deepseek.json +99 -0
- package/harnesses/gemini.json +122 -0
- package/harnesses/opencode.json +173 -0
- package/package.json +12 -2
- package/rules/core.md +7 -0
- package/rules/mdc/intake-questions.mdc +8 -0
- package/rules/mdc/slice-commit.mdc +57 -4
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/prompt.mjs +5 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agenthooks/shim.mjs +1 -1
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +31 -0
- package/src/argv.mjs +48 -3
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +163 -105
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +33 -73
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +121 -475
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +79 -1064
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +120 -962
- package/src/bench/run.mjs +185 -802
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +48 -15
- package/src/ci/dependabot.mjs +25 -55
- package/src/ci/workflow.mjs +30 -7
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +4 -13
- package/src/commands/agenthook.mjs +70 -40
- package/src/commands/bench.mjs +191 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +13 -14
- package/src/commands/compare.mjs +42 -42
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +4 -4
- package/src/commands/distill.mjs +4 -3
- package/src/commands/docs.mjs +88 -0
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +6 -9
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +26 -25
- package/src/commands/graph.mjs +2 -3
- package/src/commands/harness.mjs +6 -5
- package/src/commands/help.mjs +168 -19
- package/src/commands/hooks.mjs +119 -49
- package/src/commands/ignore.mjs +13 -8
- package/src/commands/impact.mjs +8 -15
- package/src/commands/index.mjs +318 -193
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +124 -105
- package/src/commands/lease.mjs +73 -25
- package/src/commands/lessons.mjs +10 -9
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +24 -14
- package/src/commands/mcp.mjs +2 -18
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +2 -2
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +12 -11
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +37 -16
- package/src/commands/shared.mjs +14 -19
- package/src/commands/skills.mjs +33 -32
- package/src/commands/skillscompile.mjs +7 -8
- package/src/commands/skillsinstall.mjs +7 -6
- package/src/commands/slice.mjs +220 -74
- package/src/commands/spec.mjs +167 -0
- package/src/commands/status.mjs +27 -18
- package/src/commands/sync.mjs +27 -25
- package/src/commands/tests.mjs +18 -21
- package/src/commands/tokens.mjs +21 -26
- package/src/commands/undo.mjs +5 -15
- package/src/commands/upgrade.mjs +15 -7
- package/src/commands/why.mjs +15 -21
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/compare/row.mjs +0 -2
- package/src/context.mjs +64 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +3 -2
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +14 -1
- package/src/distill/engine.mjs +5 -1
- package/src/docs/commands.mjs +65 -0
- package/src/ecosystem/records.mjs +219 -0
- package/src/explain.mjs +31 -0
- package/src/gates/index.mjs +385 -0
- package/src/graph/compile.mjs +8 -4
- package/src/graph/load.mjs +6 -2
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +7 -5
- package/src/hooks/dispatcher.mjs +209 -60
- package/src/hooks/gates.mjs +10 -43
- package/src/hooks/resolve.mjs +4 -14
- package/src/hooks/shim.mjs +5 -2
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +31 -12
- package/src/index/coverage.mjs +58 -0
- package/src/index/extract.mjs +6 -16
- package/src/index/gate.mjs +1 -1
- package/src/index/govern.mjs +26 -9
- package/src/index/impact.mjs +4 -8
- package/src/index/map.mjs +14 -15
- package/src/index/open.mjs +173 -0
- package/src/index/persist.mjs +224 -57
- package/src/index/store.mjs +82 -39
- package/src/index/tests.mjs +2 -6
- package/src/journal.mjs +94 -20
- package/src/kernel/card.mjs +37 -63
- package/src/kernel/checks.mjs +4 -10
- package/src/lease.mjs +102 -7
- package/src/ledger/index.mjs +150 -14
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/resources.mjs +7 -10
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +119 -25
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -967
- package/src/measure/corpus.mjs +150 -0
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- package/src/measure/session.mjs +3 -2
- package/src/measure/stats.mjs +538 -0
- package/src/measure/task-schema.mjs +125 -0
- package/src/measure/tasks.mjs +69 -0
- package/src/memory/decide.mjs +6 -15
- package/src/memory/lesson-text.mjs +61 -0
- package/src/memory/lessons.mjs +29 -82
- package/src/name.mjs +7 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +209 -10
- package/src/packs/manage.mjs +32 -12
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +1 -1
- package/src/plugins.mjs +22 -16
- package/src/prbody.mjs +7 -11
- package/src/projection/loaders.mjs +7 -692
- package/src/projection/lock.mjs +11 -1
- package/src/projection/manifest.mjs +29 -468
- package/src/projection/mdc.mjs +7 -8
- package/src/projection/project.mjs +126 -221
- package/src/projection/region.mjs +561 -0
- package/src/projection/rules.mjs +20 -550
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +31 -0
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +19 -22
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +10 -13
- package/src/run.mjs +98 -0
- package/src/secrets.mjs +14 -22
- package/src/state/events.mjs +129 -0
- package/src/status-snapshot.mjs +233 -0
- package/src/status.mjs +2 -6
- package/src/sys/git.mjs +82 -0
- package/src/sys/proc.mjs +151 -0
- package/src/testing/parsers.mjs +265 -0
- package/src/testing/patches.mjs +23 -0
- package/src/testing/paths.mjs +49 -0
- package/src/testing/runners.mjs +186 -0
- package/src/testing/vault.mjs +68 -0
- package/src/text/frontmatter.mjs +49 -0
- package/src/text/tokens.mjs +52 -0
- package/src/toolwords.mjs +3 -0
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +58 -11
- package/src/write/plan.mjs +82 -0
- package/templates/plan/tracker.json +1 -0
- package/docs/decisions/ADR-033-three-plan-rows-closed-by-decision-not-by-authoring.md +0 -82
- package/docs/decisions/ADR-102-the-two-queued-review-rows-are-closed-by-ruling.md +0 -49
- package/docs/decisions/README.md +0 -600
- package/src/bench/tasks.mjs +0 -36
package/src/output/index.mjs
CHANGED
|
@@ -4,9 +4,49 @@
|
|
|
4
4
|
// Same data, different rendering. Nothing here emits ANSI, prompts, spinners or
|
|
5
5
|
// boxes, because a prompt is a hang for an agent and a spinner is noise in a log.
|
|
6
6
|
//
|
|
7
|
-
// See docs/
|
|
7
|
+
// See docs/research/0002-simt-dev-cli.md section 6.
|
|
8
|
+
|
|
9
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
8
10
|
|
|
9
11
|
import { CLI } from '../name.mjs';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One call's presentation state (review 0003 Q63, Q65).
|
|
15
|
+
*
|
|
16
|
+
* This was seven module-level `let`s - the JSON failure mode, the indent default, the byte
|
|
17
|
+
* ceiling, the accumulated notices, the canonical command, the capture, the sink - and a
|
|
18
|
+
* process could hold only one of each. `dispatch` and the tests both run more than one
|
|
19
|
+
* command in a process, and an MCP server runs them concurrently: the second run's ceiling,
|
|
20
|
+
* notices and command path landed in the first run's document, which is a document
|
|
21
|
+
* describing an invocation that did not produce it.
|
|
22
|
+
*
|
|
23
|
+
* Async-local rather than threaded through a parameter: `emit` is called by seventy command
|
|
24
|
+
* modules that have no context to pass, and changing every signature is the rewrite that
|
|
25
|
+
* never lands. A store is per call and inherited by everything that call awaits.
|
|
26
|
+
*/
|
|
27
|
+
const stores = new AsyncLocalStorage();
|
|
28
|
+
|
|
29
|
+
/** What a run that never opened a store gets: the process defaults before any flag. */
|
|
30
|
+
const processState = () => ({
|
|
31
|
+
jsonFailure: false,
|
|
32
|
+
pretty: process.stdout.isTTY === true,
|
|
33
|
+
maxBytes: 0,
|
|
34
|
+
warnings: [],
|
|
35
|
+
command: null,
|
|
36
|
+
captured: null,
|
|
37
|
+
sink: null,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const fallback = processState();
|
|
41
|
+
|
|
42
|
+
function state() {
|
|
43
|
+
return stores.getStore() ?? fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Give `body` its own presentation state, so two calls in one process cannot collide. */
|
|
47
|
+
export function withOutputState(body) {
|
|
48
|
+
return stores.run(processState(), body);
|
|
49
|
+
}
|
|
10
50
|
/** Stable machine codes. A non-zero exit always carries one of these. */
|
|
11
51
|
export const EXIT = {
|
|
12
52
|
OK: 0,
|
|
@@ -40,10 +80,8 @@ const REMEDY = {
|
|
|
40
80
|
* The default answers "plain text" because that is what every run before the flags
|
|
41
81
|
* were parsed is, and those runs happen before anybody could have asked.
|
|
42
82
|
*/
|
|
43
|
-
let jsonFailure = false;
|
|
44
|
-
|
|
45
83
|
export function jsonFailMode(on = false) {
|
|
46
|
-
jsonFailure = on;
|
|
84
|
+
state().jsonFailure = on;
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
/**
|
|
@@ -56,7 +94,12 @@ export function fail(message, code = EXIT.FAIL, remedy) {
|
|
|
56
94
|
// a reader in a consumer repo got a remedy naming a file that does not exist
|
|
57
95
|
// where they are. Every remedy here is something the reader can run.
|
|
58
96
|
const resolved = remedy ?? REMEDY[code] ?? `run: ${CLI} --help`;
|
|
59
|
-
|
|
97
|
+
// A library caller asked for a result, not a line on a terminal it does not own.
|
|
98
|
+
if (state().sink !== null) {
|
|
99
|
+
state().sink.error = { message, remedy: resolved, exitCode: code };
|
|
100
|
+
return code;
|
|
101
|
+
}
|
|
102
|
+
if (state().jsonFailure) {
|
|
60
103
|
emitJson({ ok: false, error: message, remedy: resolved, exitCode: code });
|
|
61
104
|
return code;
|
|
62
105
|
}
|
|
@@ -92,8 +135,32 @@ export function writeMode(flags) {
|
|
|
92
135
|
* Truncation is always announced with an explicit line, never a silent ellipsis:
|
|
93
136
|
* an agent that cannot tell a truncated answer from a complete one will guess.
|
|
94
137
|
*/
|
|
95
|
-
|
|
138
|
+
/**
|
|
139
|
+
* Answer one command's result on the surface the caller asked for.
|
|
140
|
+
*
|
|
141
|
+
* Every command that has both a document and a text report was choosing between them by
|
|
142
|
+
* hand, in the same five lines, in more than a dozen files - and a command that chose
|
|
143
|
+
* slightly differently printed a report where a caller had asked for JSON, which is how a
|
|
144
|
+
* script gets prose (review 0003 Q63).
|
|
145
|
+
*
|
|
146
|
+
* The renderer is a thunk because the surface is decided first: a command asked for JSON
|
|
147
|
+
* must not pay to render text nobody reads, and a caller cannot forget the branch.
|
|
148
|
+
*/
|
|
149
|
+
export function present(payload, renderText, flags) {
|
|
150
|
+
if (isJson(flags)) {
|
|
151
|
+
emitJson(payload);
|
|
152
|
+
return 'json';
|
|
153
|
+
}
|
|
154
|
+
emit(renderText());
|
|
155
|
+
return 'text';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function emit(text, { maxBytes = state().maxBytes, stream = process.stdout } = {}) {
|
|
96
159
|
const body = text.endsWith('\n') ? text : `${text}\n`;
|
|
160
|
+
if (state().sink !== null) {
|
|
161
|
+
state().sink.text.push(fitText(body, maxBytes));
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
97
164
|
const bytes = Buffer.byteLength(body);
|
|
98
165
|
if (maxBytes > 0 && bytes > maxBytes) {
|
|
99
166
|
const slice = Buffer.from(body).subarray(0, maxBytes).toString('utf8');
|
|
@@ -129,10 +196,19 @@ export const SCHEMA_VERSION = 1;
|
|
|
129
196
|
* important. The default answers "is a person reading", which is the right answer
|
|
130
197
|
* before anybody sets it.
|
|
131
198
|
*/
|
|
132
|
-
|
|
199
|
+
// The byte ceiling is one flag with one reader: the envelope. Commands used to pass
|
|
200
|
+
// `flags.maxBytes ?? 0` at seventy-eight call sites, which is seventy-eight chances to pass
|
|
201
|
+
// a different number, or to forget and answer past the ceiling the caller set (review 0003
|
|
202
|
+
// Q65). A ceiling of zero or a value that is not a number is no ceiling, the same way the
|
|
203
|
+
// flag itself is read.
|
|
204
|
+
/** Set the byte ceiling every emit and every document inherits, from `--max-bytes`. */
|
|
205
|
+
export function maxBytesLimit(bytes) {
|
|
206
|
+
const value = Number(bytes);
|
|
207
|
+
state().maxBytes = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
208
|
+
}
|
|
133
209
|
|
|
134
210
|
export function jsonIndentDefault(pretty = process.stdout.isTTY === true) {
|
|
135
|
-
|
|
211
|
+
state().pretty = pretty;
|
|
136
212
|
}
|
|
137
213
|
|
|
138
214
|
/**
|
|
@@ -203,8 +279,127 @@ function fitJsonArrays(payload, maxBytes, pretty) {
|
|
|
203
279
|
return null;
|
|
204
280
|
}
|
|
205
281
|
|
|
206
|
-
|
|
207
|
-
|
|
282
|
+
/**
|
|
283
|
+
* Warnings a run accumulated, carried into the JSON document it prints.
|
|
284
|
+
*
|
|
285
|
+
* The same reason stderr is separate from stdout: a caller reading a document cannot
|
|
286
|
+
* see a note printed beside it. A command that ignored half of what it was asked still
|
|
287
|
+
* has to succeed or fail the same way, so the note belongs IN the result, and the text
|
|
288
|
+
* mode keeps the stderr line it already had (review 0003 Q62).
|
|
289
|
+
*/
|
|
290
|
+
/**
|
|
291
|
+
* The output sink for a library call, or null for a process (review 0003 Q63).
|
|
292
|
+
*
|
|
293
|
+
* `run(command, input)` answers with the text, the documents, the notes and the failure a
|
|
294
|
+
* command produced instead of writing them. The alternative - threading a `result` return
|
|
295
|
+
* through seventy command modules - is the rewrite that never finishes, and the printing
|
|
296
|
+
* commands have no return value to thread: they `return EXIT.OK` after calling `emit`.
|
|
297
|
+
*
|
|
298
|
+
* A sink is per call and never nested. The dispatcher keeps its own JSON capture below and
|
|
299
|
+
* is not this: a sink answers "what did the command produce", the capture answers "hold the
|
|
300
|
+
* document until the exit code exists".
|
|
301
|
+
*/
|
|
302
|
+
/** Collect a command's output instead of writing it, and answer with what it produced. */
|
|
303
|
+
export async function withSink(body) {
|
|
304
|
+
if (state().sink !== null) throw new Error('an output sink is already open');
|
|
305
|
+
const collecting = { text: [], documents: [], notes: [], error: null };
|
|
306
|
+
state().sink = collecting;
|
|
307
|
+
try {
|
|
308
|
+
const code = await body();
|
|
309
|
+
return {
|
|
310
|
+
code: code ?? EXIT.OK,
|
|
311
|
+
text: collecting.text.join(''),
|
|
312
|
+
documents: collecting.documents,
|
|
313
|
+
notes: collecting.notes,
|
|
314
|
+
error: collecting.error,
|
|
315
|
+
};
|
|
316
|
+
} finally {
|
|
317
|
+
state().sink = null;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The same cut `emit` makes on a stream, applied to a string: the ceiling is a promise
|
|
323
|
+
* whichever surface reads it, and a library caller that got bytes past the limit it asked
|
|
324
|
+
* for would be paying for what the flag exists to refuse.
|
|
325
|
+
*/
|
|
326
|
+
function fitText(body, maxBytes) {
|
|
327
|
+
const bytes = Buffer.byteLength(body);
|
|
328
|
+
if (maxBytes <= 0 || bytes <= maxBytes) return body;
|
|
329
|
+
const slice = Buffer.from(body).subarray(0, maxBytes).toString('utf8');
|
|
330
|
+
const cut = slice.lastIndexOf('\n');
|
|
331
|
+
return cut > 0
|
|
332
|
+
? `${slice.slice(0, cut)}\nTRUNCATED ${bytes - maxBytes} bytes omitted, rerun with --max-bytes 0\n`
|
|
333
|
+
: `${slice}TRUNCATED ${bytes - maxBytes} bytes omitted, rerun with --max-bytes 0\n`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// The path the dispatcher resolved. A command should not restate it: the ones that did
|
|
337
|
+
// restated it wrongly (`plugin` printed `plugin claude`, `sync` printed `sync --check`),
|
|
338
|
+
// and a document whose `command` disagrees with the argv that produced it is worse than
|
|
339
|
+
// one with no `command` at all (review 0003 Q65).
|
|
340
|
+
/** Set the command path every JSON document will carry, or clear it with null. */
|
|
341
|
+
export function jsonCommand(path) {
|
|
342
|
+
state().command = path ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// A document cannot name the exit code before the command has returned one. The
|
|
346
|
+
// dispatcher captures what a command emits, then writes it once with the code attached;
|
|
347
|
+
// writing first is how `ok: false` shipped alongside exit 0 (review 0003 Q65).
|
|
348
|
+
/** Collect documents instead of writing them, so the dispatcher can add the exit code. */
|
|
349
|
+
export function jsonCaptureStart() {
|
|
350
|
+
state().captured = [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The captured documents and a reset, or an empty list when nothing was captured. */
|
|
354
|
+
export function jsonCaptureTake() {
|
|
355
|
+
const docs = state().captured;
|
|
356
|
+
state().captured = null;
|
|
357
|
+
return docs;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Record a warning for the document this run prints. */
|
|
361
|
+
export function warn(message) {
|
|
362
|
+
state().warnings.push(message);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Drop what a previous run in this process recorded.
|
|
367
|
+
*
|
|
368
|
+
* The collector is per process and a process can dispatch more than once - the MCP
|
|
369
|
+
* server and the tests both do. Without this, one command's notice appeared in the
|
|
370
|
+
* next command's document, which parity between the two surfaces caught (review 0003
|
|
371
|
+
* Q62).
|
|
372
|
+
*/
|
|
373
|
+
export function clearWarnings() {
|
|
374
|
+
state().warnings = [];
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The warnings this run has recorded, in order. */
|
|
378
|
+
export function warnings() {
|
|
379
|
+
return state().warnings;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function emitJson(value, { stream = process.stdout, pretty = state().pretty, maxBytes = state().maxBytes } = {}) {
|
|
383
|
+
// `notices`, not `warnings`: `status` already reports a warning COUNT under that key,
|
|
384
|
+
// and a document cannot mean two things by one name. The collision is named here
|
|
385
|
+
// rather than bent into either shape (review 0003 Q62).
|
|
386
|
+
const carried =
|
|
387
|
+
state().warnings.length > 0 && value !== null && typeof value === 'object' && !Array.isArray(value) && value.notices === undefined
|
|
388
|
+
? { ...value, notices: state().warnings }
|
|
389
|
+
: value;
|
|
390
|
+
const stamped =
|
|
391
|
+
state().command !== null && carried !== null && typeof carried === 'object' && !Array.isArray(carried)
|
|
392
|
+
? { ...carried, command: state().command }
|
|
393
|
+
: carried;
|
|
394
|
+
if (state().sink !== null) {
|
|
395
|
+
state().sink.documents.push(withSchemaVersion(stamped));
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
if (state().captured !== null) {
|
|
399
|
+
state().captured.push({ value: stamped });
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
const payload = withSchemaVersion(stamped);
|
|
208
403
|
const text = `${stringifyJson(payload, pretty)}\n`;
|
|
209
404
|
// JSON is never byte-truncated: a cut document parses as a lie, and exit 0 calls it
|
|
210
405
|
// true. Arrays shrink with a `truncated` record naming what lost how much, and a
|
|
@@ -239,5 +434,9 @@ export function emitJson(value, { stream = process.stdout, pretty = prettyDefaul
|
|
|
239
434
|
* diagnostics that accompany a real result, never for the result itself.
|
|
240
435
|
*/
|
|
241
436
|
export function note(message, { stream = process.stderr } = {}) {
|
|
437
|
+
if (state().sink !== null) {
|
|
438
|
+
state().sink.notes.push(message);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
242
441
|
stream.write(`${message}\n`);
|
|
243
442
|
}
|
package/src/packs/manage.mjs
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import { execFile } from 'node:child_process';
|
|
17
17
|
import { mkdir, readdir, readFile, stat } from 'node:fs/promises';
|
|
18
|
-
import {
|
|
18
|
+
import { applyPlan, change } from '../write/plan.mjs';
|
|
19
|
+
import { readBefore } from '../diff.mjs';
|
|
19
20
|
import { createHash } from 'node:crypto';
|
|
20
21
|
import { basename, dirname, join } from 'node:path';
|
|
21
22
|
import { promisify } from 'node:util';
|
|
@@ -23,6 +24,7 @@ import { promisify } from 'node:util';
|
|
|
23
24
|
import { PACK_DIR, packDigest, packNameProblem } from '../packs.mjs';
|
|
24
25
|
import { readManifest } from '../projection/manifest.mjs';
|
|
25
26
|
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
27
|
+
import { parseFrontmatter } from '../text/frontmatter.mjs';
|
|
26
28
|
|
|
27
29
|
const exec = promisify(execFile);
|
|
28
30
|
|
|
@@ -311,10 +313,10 @@ export async function lintPack(dir, { requireMeasured = false, declared = null }
|
|
|
311
313
|
fail(rel, 'pack-rule-unreadable', 'the file cannot be read', 'restore it or remove it from the pack');
|
|
312
314
|
continue;
|
|
313
315
|
}
|
|
314
|
-
const
|
|
316
|
+
const { front } = parseFrontmatter(text);
|
|
315
317
|
// Multiline: the id sits on its own line inside the frontmatter block, and
|
|
316
318
|
// without `m` the anchors hold the whole block instead of the line.
|
|
317
|
-
const id =
|
|
319
|
+
const id = front === null ? null : /^simt-id:\s*(.+?)\s*$/m.exec(front)?.[1] ?? null;
|
|
318
320
|
if (id === null) {
|
|
319
321
|
fail(rel, 'pack-rule-without-id', 'the rule has no `simt-id`: without one nothing selects it and nothing explains it', 'give the rule a `simt-id` in its frontmatter');
|
|
320
322
|
}
|
|
@@ -511,6 +513,23 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
511
513
|
return { pack: linted.pack, ok: false, published: false, skipped: false, planned: [], findings };
|
|
512
514
|
}
|
|
513
515
|
|
|
516
|
+
// A preview is a preview. The registry read below is the one network call in this
|
|
517
|
+
// command, and running it to print a plan made `pack publish` — a preview by default
|
|
518
|
+
// — a network operation that failed offline without `--yes` (review 0003 Q58).
|
|
519
|
+
if (!apply) {
|
|
520
|
+
return {
|
|
521
|
+
pack: linted.pack,
|
|
522
|
+
ok: true,
|
|
523
|
+
published: false,
|
|
524
|
+
skipped: false,
|
|
525
|
+
planned: [
|
|
526
|
+
`npm view ${pkg}@${pack.version} (check whether it is already live)`,
|
|
527
|
+
`npm publish (in ${dir})`,
|
|
528
|
+
],
|
|
529
|
+
findings,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
514
533
|
// Idempotent, the way the release twin is: a version that is already live is
|
|
515
534
|
// skipped rather than republished, so a re-run after an interruption continues
|
|
516
535
|
// where it stopped.
|
|
@@ -534,9 +553,6 @@ export async function publishPack(dir, { apply = false } = {}) {
|
|
|
534
553
|
}
|
|
535
554
|
|
|
536
555
|
const planned = [`npm publish (in ${dir})`];
|
|
537
|
-
if (!apply) {
|
|
538
|
-
return { pack: linted.pack, ok: true, published: false, skipped: false, planned, findings };
|
|
539
|
-
}
|
|
540
556
|
try {
|
|
541
557
|
await exec('npm', ['publish'], { cwd: dir });
|
|
542
558
|
} catch (error) {
|
|
@@ -563,10 +579,14 @@ export async function writeScaffold(dir, files, root = dirname(dir)) {
|
|
|
563
579
|
if (error?.message?.startsWith('refusing to scaffold')) throw error;
|
|
564
580
|
}
|
|
565
581
|
const journal = [];
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
await
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
582
|
+
await applyPlan(
|
|
583
|
+
root,
|
|
584
|
+
await Promise.all(
|
|
585
|
+
files.map(async (file) => {
|
|
586
|
+
const abs = join(dir, file.path);
|
|
587
|
+
return change({ path: abs, before: await readBefore(abs), after: file.text, owner: 'pack new', reason: 'the pack is being created' });
|
|
588
|
+
}),
|
|
589
|
+
),
|
|
590
|
+
{ mode: { apply: true }, label: 'pack new' },
|
|
591
|
+
);
|
|
572
592
|
}
|
package/src/packs.mjs
CHANGED
|
@@ -72,8 +72,26 @@ export async function packDigest(dir, { exclude = [] } = {}) {
|
|
|
72
72
|
}
|
|
73
73
|
for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
74
74
|
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
75
|
-
if (entry.isDirectory())
|
|
76
|
-
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
await walk(join(d, entry.name), rel);
|
|
77
|
+
} else if (entry.isSymbolicLink()) {
|
|
78
|
+
// A link is not vendored bytes, and the loader follows it: the old walk skipped
|
|
79
|
+
// links while the projection read through them, so a rule reached by a link was
|
|
80
|
+
// projected without being covered by the pin (review 0003 Q58). A link to a
|
|
81
|
+
// file is hashed through its target, because `readFile` below follows it. A link
|
|
82
|
+
// to a directory is refused: recursing would let a pack's digest describe a tree
|
|
83
|
+
// it does not carry.
|
|
84
|
+
const target = await stat(join(d, entry.name)).catch(() => null);
|
|
85
|
+
if (target?.isDirectory()) {
|
|
86
|
+
const error = new Error(
|
|
87
|
+
`${rel} is a symbolic link to a directory; a pack is vendored bytes, and its digest cannot cover a tree it does not carry`,
|
|
88
|
+
);
|
|
89
|
+
error.code = 'PACK_SYMLINK';
|
|
90
|
+
error.path = rel;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (!excluded.has(rel)) files.push(rel);
|
|
94
|
+
} else if (entry.isFile() && !excluded.has(rel)) files.push(rel);
|
|
77
95
|
}
|
|
78
96
|
};
|
|
79
97
|
await walk(dir);
|
package/src/plan/index.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// An agent resuming a long plan should read one slice, not the document. Which
|
|
4
4
|
// directories are read, which columns count, and which words count as done are
|
|
5
5
|
// the repository's template (src/plan/template.mjs). The tracker template
|
|
6
|
-
// delivered with the tool reads docs/plans and docs/reviews and treats `done`
|
|
6
|
+
// delivered with the tool reads docs/plans, docs/research and docs/reviews and treats `done`
|
|
7
7
|
// as complete.
|
|
8
8
|
// A dependency may name another document (`review 0002 S41`) or one of its
|
|
9
9
|
// waves (`review 0002 Wave 0`). Ids that follow a document prefix stay in that
|
package/src/plugins.mjs
CHANGED
|
@@ -29,10 +29,16 @@ import { fileURLToPath } from 'node:url';
|
|
|
29
29
|
import { readBefore } from './diff.mjs';
|
|
30
30
|
import { hookDocument } from './agenthooks/render.mjs';
|
|
31
31
|
import { desiredRegion } from './projection/rules.mjs';
|
|
32
|
+
import { harnessPlugins } from './harness/records.mjs';
|
|
32
33
|
import { PACKAGE_NAME, BIN_REL } from './name.mjs';
|
|
33
34
|
|
|
35
|
+
// The plugin layouts, as records (Q71): where each kind's runtime, entry point and hooks
|
|
36
|
+
// document land. A new kind is a new record, not a branch in each helper below.
|
|
37
|
+
const PLUGINS = harnessPlugins();
|
|
38
|
+
const pluginOf = (kind) => PLUGINS.find((r) => r.id === kind);
|
|
39
|
+
|
|
34
40
|
/** The kinds `simt plugin` builds, in the order the help line names them. */
|
|
35
|
-
export const PLUGIN_KINDS =
|
|
41
|
+
export const PLUGIN_KINDS = PLUGINS.map((r) => r.id);
|
|
36
42
|
|
|
37
43
|
/**
|
|
38
44
|
* The package this code ships in: the runtime a generated package copies. Resolved
|
|
@@ -45,37 +51,37 @@ function packageRoot() {
|
|
|
45
51
|
|
|
46
52
|
/** Where the runtime copy lands inside a package, relative to the package root. */
|
|
47
53
|
function runtimeDir(kind) {
|
|
48
|
-
return kind
|
|
54
|
+
return pluginOf(kind).runtimeDir;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
/** The entry point a package's config names: its own copy of the runtime, not PATH. */
|
|
52
58
|
function entryPoint(kind) {
|
|
53
|
-
const
|
|
59
|
+
const { runtimeDir: dir, rootVar } = pluginOf(kind);
|
|
60
|
+
const bin = join(dir, BIN_REL);
|
|
54
61
|
// The vendor's own variables must survive as literal text in the generated config, so
|
|
55
62
|
// they are escaped here: an unescaped ${...} would interpolate a JS binding that does
|
|
56
63
|
// not exist and ship the string "undefined" into the package.
|
|
57
|
-
if (
|
|
58
|
-
if (kind === 'gemini') return `node "\${extensionPath}/${bin}"`;
|
|
64
|
+
if (rootVar) return `node "\${${rootVar}}/${bin}"`;
|
|
59
65
|
// Cursor runs from the repository root, so a relative path is the honest one.
|
|
60
66
|
return `node ${bin}`;
|
|
61
67
|
}
|
|
62
68
|
|
|
63
69
|
/** The project root a package's hook resolves against, from the vendor's env var. */
|
|
64
70
|
function rootFlag(kind) {
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
const { projectVar } = pluginOf(kind);
|
|
72
|
+
if (!projectVar) {
|
|
73
|
+
// Gemini documents no project-dir env var, but its base input carries cwd, which the
|
|
74
|
+
// adapter reads; passing a flag the vendor never sets would only expand to "".
|
|
75
|
+
return '';
|
|
76
|
+
}
|
|
77
|
+
return ` --root "$${projectVar}"`;
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
/** Where the hooks document lands inside a package, relative to the package root.
|
|
80
|
+
/** Where the hooks document lands inside a package, relative to the package root.
|
|
81
|
+
* OpenCode is the one whose document is a file the tool loads whole (its root
|
|
82
|
+
* `server` file) rather than a hooks config to merge. */
|
|
73
83
|
function hooksPath(kind) {
|
|
74
|
-
|
|
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');
|
|
84
|
+
return pluginOf(kind).hooksPath;
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
package/src/prbody.mjs
CHANGED
|
@@ -12,18 +12,15 @@
|
|
|
12
12
|
// falls back to the working-tree diff against HEAD: a report that refused
|
|
13
13
|
// without a base would be useless on a repository with a single branch.
|
|
14
14
|
|
|
15
|
-
import { execFile } from 'node:child_process';
|
|
16
15
|
import { readFile, readdir } from 'node:fs/promises';
|
|
17
16
|
import { join } from 'node:path';
|
|
18
|
-
import { promisify } from 'node:util';
|
|
19
17
|
|
|
20
18
|
import { readLedger, treeId } from './ledger/index.mjs';
|
|
21
19
|
import { readCheckCache } from './status.mjs';
|
|
22
20
|
import { readManifest } from './projection/manifest.mjs';
|
|
23
|
-
import {
|
|
21
|
+
import { parseFrontmatter } from './text/frontmatter.mjs';
|
|
24
22
|
import { decisionsDir } from './decisions/dir.mjs';
|
|
25
|
-
|
|
26
|
-
const exec = promisify(execFile);
|
|
23
|
+
import { git, gitText } from './sys/git.mjs';
|
|
27
24
|
|
|
28
25
|
/**
|
|
29
26
|
* The ref the body diffs against: the override when given, else the first
|
|
@@ -33,7 +30,7 @@ const exec = promisify(execFile);
|
|
|
33
30
|
async function resolveBase(root, override = null) {
|
|
34
31
|
if (override !== null && override !== undefined && String(override) !== '') {
|
|
35
32
|
try {
|
|
36
|
-
await
|
|
33
|
+
await git(root, ['rev-parse', '--verify', '--quiet', `${override}^{commit}`]);
|
|
37
34
|
return { base: String(override), error: null };
|
|
38
35
|
} catch {
|
|
39
36
|
return { base: null, error: `unknown base ${override}` };
|
|
@@ -41,7 +38,7 @@ async function resolveBase(root, override = null) {
|
|
|
41
38
|
}
|
|
42
39
|
for (const candidate of ['origin/main', 'main', 'origin/master', 'master']) {
|
|
43
40
|
try {
|
|
44
|
-
await
|
|
41
|
+
await git(root, ['merge-base', 'HEAD', candidate]);
|
|
45
42
|
return { base: candidate, error: null };
|
|
46
43
|
} catch {
|
|
47
44
|
// Not present or shares no history; the next candidate may.
|
|
@@ -66,8 +63,7 @@ async function changedPaths(root, base) {
|
|
|
66
63
|
};
|
|
67
64
|
if (base !== null) {
|
|
68
65
|
try {
|
|
69
|
-
const
|
|
70
|
-
cwd: root,
|
|
66
|
+
const stdout = await gitText(root, ['diff', '--name-only', `${base}...HEAD`], {
|
|
71
67
|
maxBuffer: 16 << 20,
|
|
72
68
|
});
|
|
73
69
|
add(stdout);
|
|
@@ -81,7 +77,7 @@ async function changedPaths(root, base) {
|
|
|
81
77
|
['ls-files', '--others', '--exclude-standard'],
|
|
82
78
|
]) {
|
|
83
79
|
try {
|
|
84
|
-
const
|
|
80
|
+
const stdout = await gitText(root, args, { maxBuffer: 16 << 20 });
|
|
85
81
|
add(stdout);
|
|
86
82
|
} catch {
|
|
87
83
|
// Not a repository, or no commit yet: the body reports the absence.
|
|
@@ -113,7 +109,7 @@ async function changedDecisions(root, paths) {
|
|
|
113
109
|
let status = 'unknown';
|
|
114
110
|
try {
|
|
115
111
|
const text = await readFile(join(root, name), 'utf8');
|
|
116
|
-
const { body } =
|
|
112
|
+
const { body } = parseFrontmatter(text);
|
|
117
113
|
const heading = /^#\s+(.*)$/m.exec(body)?.[1] ?? id;
|
|
118
114
|
title = heading.replace(/^ADR-\d{3,}\s*[—–-]\s*/i, '').trim() || id;
|
|
119
115
|
status = /^Status:\s*(.+)$/im.exec(body)?.[1].trim() ?? 'unknown';
|