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/bench/variants.mjs
CHANGED
|
@@ -18,20 +18,19 @@ import { plan } from '../projection/project.mjs';
|
|
|
18
18
|
import { isEligible, loadRules, readDeclared, select } from '../projection/rules.mjs';
|
|
19
19
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
20
20
|
import { CANARY_ALPHA, DEFAULT_CANARY_SIZE, DEL_R_TOLERANCE, shouldEscalateCanary } from './ablate.mjs';
|
|
21
|
+
import { evaluateRunRecord, loadPrices } from '../measure/metrics.mjs';
|
|
22
|
+
import { loadRunRecords } from '../measure/records.mjs';
|
|
21
23
|
import {
|
|
22
24
|
DEFAULT_BOOTSTRAP_SAMPLES,
|
|
23
25
|
DEFAULT_BOOTSTRAP_SEED,
|
|
24
26
|
collapseArmByTask,
|
|
25
27
|
compareArms,
|
|
26
|
-
evaluateRunRecord,
|
|
27
28
|
holmAdjust,
|
|
28
|
-
|
|
29
|
-
loadRunRecords,
|
|
30
|
-
} from './report.mjs';
|
|
29
|
+
} from '../measure/stats.mjs';
|
|
31
30
|
|
|
32
31
|
/**
|
|
33
32
|
* Harnesses that grade, not answer: `gold` applies the oracle patch and `noop` does
|
|
34
|
-
* nothing (`src/bench/adapters.json`, `kind: "
|
|
33
|
+
* nothing (`src/bench/adapters.json`, `kind: "oracle"`). Their runs measure the
|
|
35
34
|
* grader, not the rule text, so they must never close a variant experiment by
|
|
36
35
|
* writing a lock entry (Q45). A test asserts this set matches the adapter table.
|
|
37
36
|
*/
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Isolated workspaces for a benchmarked run.
|
|
2
|
+
//
|
|
3
|
+
// One base tree is exported per task and copied per run (review 0003 Q78): every arm and
|
|
4
|
+
// repetition used to unpack the same `git archive` again, which made the cost of the
|
|
5
|
+
// harness include the cost of exporting the repository. The copy is a fresh directory
|
|
6
|
+
// with no `.git` of its own until the arm has materialised, so two runs of one task can
|
|
7
|
+
// never see each other's edits.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { STATE_DIR } from '../name.mjs';
|
|
14
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
15
|
+
import { sanitizedEnv, spawnBounded } from '../sys/proc.mjs';
|
|
16
|
+
import { materialiseArm } from './arms.mjs';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_GIT_TIMEOUT_MS = 120_000;
|
|
19
|
+
|
|
20
|
+
/** Run one bounded child process with a sanitised environment. */
|
|
21
|
+
export function runCmd(cmd, args, { cwd, env, input = null, timeoutMs = DEFAULT_GIT_TIMEOUT_MS } = {}) {
|
|
22
|
+
return spawnBounded(cmd, args, { cwd, env: sanitizedEnv(env), input, timeoutMs });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Apply a bounded, order-preserving pool over `items`.
|
|
27
|
+
*
|
|
28
|
+
* @template T, R
|
|
29
|
+
* @param {T[]} items
|
|
30
|
+
* @param {number} concurrency
|
|
31
|
+
* @param {(item: T, index: number) => Promise<R>} fn
|
|
32
|
+
* @returns {Promise<R[]>} results in input order
|
|
33
|
+
*/
|
|
34
|
+
export async function mapPool(items, concurrency, fn) {
|
|
35
|
+
const results = new Array(items.length);
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
async function worker() {
|
|
38
|
+
while (true) {
|
|
39
|
+
const index = cursor;
|
|
40
|
+
cursor += 1;
|
|
41
|
+
if (index >= items.length) return;
|
|
42
|
+
results[index] = await fn(items[index], index);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const count = Math.max(1, Math.min(concurrency, items.length));
|
|
46
|
+
await Promise.all(Array.from({ length: count }, () => worker()));
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Export one commit into a fresh directory: the tree every run of a task starts from.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} root
|
|
54
|
+
* @param {string} baseSha
|
|
55
|
+
* @param {string} parentDir
|
|
56
|
+
* @returns {Promise<string>} the directory holding the unpacked tree
|
|
57
|
+
*/
|
|
58
|
+
export async function prepareBaseTree(root, baseSha, parentDir) {
|
|
59
|
+
const baseDir = await mkdtemp(join(parentDir, 'base-'));
|
|
60
|
+
const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
|
|
61
|
+
if (archiveRes.code !== 0) {
|
|
62
|
+
await rm(baseDir, { recursive: true, force: true }).catch(() => {});
|
|
63
|
+
throw new Error(`git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`);
|
|
64
|
+
}
|
|
65
|
+
const tarRes = await runCmd('tar', ['-xf', '-', '-C', baseDir], {
|
|
66
|
+
cwd: baseDir,
|
|
67
|
+
input: archiveRes.stdout,
|
|
68
|
+
});
|
|
69
|
+
if (tarRes.code !== 0) {
|
|
70
|
+
await rm(baseDir, { recursive: true, force: true }).catch(() => {});
|
|
71
|
+
throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
|
|
72
|
+
}
|
|
73
|
+
return baseDir;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create the workspace one run is measured in: the base tree, the arm materialised over
|
|
78
|
+
* it, one commit, and no tool state.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} root
|
|
81
|
+
* @param {string} baseSha
|
|
82
|
+
* @param {object} armSpec
|
|
83
|
+
* @param {object} [task]
|
|
84
|
+
* @param {{parentDir?: string, baseDir?: string}} [options]
|
|
85
|
+
* @returns {Promise<{slotDir: string, workDir: string, initialHead: string,
|
|
86
|
+
* env: Record<string, string>, promptPrefix: string}>}
|
|
87
|
+
*/
|
|
88
|
+
export async function prepareIsolatedWorkspace(root, baseSha, armSpec, task = {}, { parentDir, baseDir } = {}) {
|
|
89
|
+
const slotDir = await mkdtemp(join(parentDir ?? tmpdir(), 'bench-run-'));
|
|
90
|
+
const workDir = join(slotDir, 'workspace');
|
|
91
|
+
await mkdir(workDir, { recursive: true });
|
|
92
|
+
|
|
93
|
+
if (baseDir) {
|
|
94
|
+
const copied = await runCmd('cp', ['-a', `${baseDir}/.`, workDir], { cwd: workDir });
|
|
95
|
+
if (copied.code !== 0) {
|
|
96
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
97
|
+
throw new Error(`copying the base tree failed for ${baseSha}: ${copied.stderr.toString('utf8').trim()}`);
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
const archiveRes = await runCmd('git', ['archive', '--format=tar', baseSha], { cwd: root });
|
|
101
|
+
if (archiveRes.code !== 0) {
|
|
102
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
103
|
+
throw new Error(`git archive failed for ${baseSha}: ${archiveRes.stderr.toString('utf8').trim()}`);
|
|
104
|
+
}
|
|
105
|
+
const tarRes = await runCmd('tar', ['-xf', '-', '-C', workDir], { cwd: workDir, input: archiveRes.stdout });
|
|
106
|
+
if (tarRes.code !== 0) {
|
|
107
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
108
|
+
throw new Error(`tar unpack failed for ${baseSha}: ${tarRes.stderr.toString('utf8').trim()}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Lessons and local profiles live outside the archived commit, so an adopted
|
|
113
|
+
// repository's memory reaches the arm without being written into its base tree.
|
|
114
|
+
const rootLessonsDir = join(root, 'docs', 'lessons');
|
|
115
|
+
const rootLessonFiles = (await readdir(rootLessonsDir).catch(() => [])).filter((f) => f.endsWith('.md'));
|
|
116
|
+
if (rootLessonFiles.length > 0) {
|
|
117
|
+
const destLessonsDir = join(workDir, 'docs', 'lessons');
|
|
118
|
+
await mkdir(destLessonsDir, { recursive: true });
|
|
119
|
+
for (const file of rootLessonFiles) {
|
|
120
|
+
const target = join(destLessonsDir, file);
|
|
121
|
+
if (!existsSync(target)) {
|
|
122
|
+
await writeFile(target, await readFile(join(rootLessonsDir, file), 'utf8'), 'utf8');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const rootProfilesDir = join(root, STATE_DIR, 'profiles');
|
|
128
|
+
const rootProfileFiles = (await readdir(rootProfilesDir).catch(() => [])).filter((f) => f.endsWith('.md'));
|
|
129
|
+
if (rootProfileFiles.length > 0) {
|
|
130
|
+
const destProfilesDir = join(workDir, STATE_DIR, 'profiles');
|
|
131
|
+
await mkdir(destProfilesDir, { recursive: true });
|
|
132
|
+
for (const file of rootProfileFiles) {
|
|
133
|
+
await writeFile(join(destProfilesDir, file), await readFile(join(rootProfilesDir, file), 'utf8'), 'utf8');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await runCmd('git', ['init', '-q'], { cwd: workDir });
|
|
138
|
+
await runCmd('git', ['config', 'user.name', 'bench'], { cwd: workDir });
|
|
139
|
+
await runCmd('git', ['config', 'user.email', 'bench@local'], { cwd: workDir });
|
|
140
|
+
await runCmd('git', ['config', 'commit.gpgsign', 'false'], { cwd: workDir });
|
|
141
|
+
|
|
142
|
+
// The repository exists before an arm materialises: the `brief` arm assembles its
|
|
143
|
+
// packet from git's answer, which a directory that is not yet a repository cannot give.
|
|
144
|
+
const { env, promptPrefix } = await materialiseArm(workDir, armSpec, task);
|
|
145
|
+
// Assembling a packet writes tool state. It belongs to no arm, and leaving it in the
|
|
146
|
+
// base commit would make the `brief` workspace differ by an index as well as by context.
|
|
147
|
+
await rm(join(workDir, LOCAL_DIR), { recursive: true, force: true }).catch(() => {});
|
|
148
|
+
|
|
149
|
+
await runCmd('git', ['add', '-A'], { cwd: workDir });
|
|
150
|
+
const commitRes = await runCmd('git', ['commit', '-qm', 'base', '--allow-empty', '--no-verify'], {
|
|
151
|
+
cwd: workDir,
|
|
152
|
+
});
|
|
153
|
+
if (commitRes.code !== 0) {
|
|
154
|
+
await rm(slotDir, { recursive: true, force: true }).catch(() => {});
|
|
155
|
+
throw new Error(`git commit failed in isolated workspace: ${commitRes.stderr.toString('utf8').trim()}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const headRes = await runCmd('git', ['rev-parse', 'HEAD'], { cwd: workDir });
|
|
159
|
+
return { slotDir, workDir, initialHead: headRes.stdout.toString('utf8').trim(), env, promptPrefix };
|
|
160
|
+
}
|
package/src/brief/assemble.mjs
CHANGED
|
@@ -34,7 +34,10 @@
|
|
|
34
34
|
// command and the tool leave it on. It is inert either way in a repository that declared no
|
|
35
35
|
// servers.
|
|
36
36
|
|
|
37
|
+
import { join } from 'node:path';
|
|
38
|
+
|
|
37
39
|
import { CLI, STATE_DIR } from '../name.mjs';
|
|
40
|
+
import { parse as parseRegion } from '../projection/markers.mjs';
|
|
38
41
|
import { DEFAULT_BRIEF_BYTES, buildPacket, collect } from './packet.mjs';
|
|
39
42
|
import { classifyTask, cardVerbs } from './kind.mjs';
|
|
40
43
|
import { briefAdvice } from '../advice/advice.mjs';
|
|
@@ -42,15 +45,17 @@ import { commandCard } from '../kernel/card.mjs';
|
|
|
42
45
|
import { decisionHits, scopeHits, sourceRoots } from '../index/govern.mjs';
|
|
43
46
|
import { embedTaskQuery, rankFiles, renderMap } from '../index/map.mjs';
|
|
44
47
|
import { searchFtsBm25 } from '../index/store.mjs';
|
|
45
|
-
import {
|
|
48
|
+
import { coverage } from '../index/coverage.mjs';
|
|
46
49
|
import { loadRegistry } from '../graph/load.mjs';
|
|
47
50
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
48
|
-
import { closure, rankSkills, selectWithinBudget
|
|
51
|
+
import { closure, rankSkills, selectWithinBudget } from '../resolve/resolve.mjs';
|
|
52
|
+
import { tokenize } from '../text/tokens.mjs';
|
|
49
53
|
import { loadGraph } from '../commands/map.mjs';
|
|
50
54
|
import { dirtyPaths, parsePathList } from '../commands/skills.mjs';
|
|
51
55
|
import { decisionRecords, lessonScopes } from '../commands/why.mjs';
|
|
52
56
|
import { readFederation } from '../federation/config.mjs';
|
|
53
57
|
import { consult } from '../federation/query.mjs';
|
|
58
|
+
import { gitText } from '../sys/git.mjs';
|
|
54
59
|
|
|
55
60
|
/**
|
|
56
61
|
* How many ranked files the packet may consider as candidates for other sections.
|
|
@@ -81,22 +86,32 @@ const MAP_FACTOR = 8;
|
|
|
81
86
|
* space is never part of a task's scope.
|
|
82
87
|
*/
|
|
83
88
|
|
|
84
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Every file git tracks that carries a generated region, one `git grep` away.
|
|
91
|
+
*
|
|
92
|
+
* A file that merely mentions the marker is not carrying a region: a rule file, a review
|
|
93
|
+
* and this test all quote `simt:begin`, and warning that they are generated sends a reader
|
|
94
|
+
* to edit a source file that has no generated bytes at all (review 0003 Q52). The grep is
|
|
95
|
+
* only the candidate list; the region parser decides.
|
|
96
|
+
*/
|
|
85
97
|
async function generatedRegions(root) {
|
|
86
|
-
const {
|
|
87
|
-
|
|
98
|
+
const { readFile } = await import('node:fs/promises');
|
|
99
|
+
let candidates;
|
|
88
100
|
try {
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
['grep', '-lF', '--', `${'simt'}:begin`],
|
|
92
|
-
{ cwd: root, maxBuffer: 8 << 20 },
|
|
93
|
-
);
|
|
94
|
-
return stdout.split('\n').filter((line) => line.length > 0).sort();
|
|
101
|
+
const stdout = await gitText(root, ['grep', '-lF', '--', `${'simt'}:begin`], { maxBuffer: 8 << 20 });
|
|
102
|
+
candidates = (stdout ?? '').split('\n').filter((line) => line.length > 0).sort();
|
|
95
103
|
} catch {
|
|
96
104
|
// No match is exit 1, and a directory that is not a repository fails too. Both mean
|
|
97
105
|
// the same thing to a reader: no generated region was found to warn about.
|
|
98
106
|
return [];
|
|
99
107
|
}
|
|
108
|
+
const carrying = [];
|
|
109
|
+
for (const path of candidates) {
|
|
110
|
+
const text = await readFile(join(root, path), 'utf8').catch(() => null);
|
|
111
|
+
if (text === null) continue;
|
|
112
|
+
if (parseRegion(text, 'html').status === 'ok') carrying.push(path);
|
|
113
|
+
}
|
|
114
|
+
return carrying;
|
|
100
115
|
}
|
|
101
116
|
|
|
102
117
|
/**
|
|
@@ -113,6 +128,9 @@ async function generatedRegions(root) {
|
|
|
113
128
|
* @param {boolean} [options.build] whether an empty index may be built here
|
|
114
129
|
* @param {boolean} [options.federate] whether a declared external server may be asked one
|
|
115
130
|
* question; a delivered packet turns it off to keep its 300 ms bar
|
|
131
|
+
* @param {string[]} [options.commands] the dispatchable command paths (`COMMANDS`). A
|
|
132
|
+
* decision's `Affects:` token is a command only when the tool has that command, so a
|
|
133
|
+
* packet that does not know them reports every one of them as unresolved
|
|
116
134
|
* @returns a packet, or `{ error }` carrying the exit code a failure already reported, or
|
|
117
135
|
* `{ cold: true }` when the index is empty and this caller may not build one
|
|
118
136
|
*/
|
|
@@ -124,12 +142,13 @@ export async function assembleBrief({
|
|
|
124
142
|
flags = {},
|
|
125
143
|
build = true,
|
|
126
144
|
federate = true,
|
|
145
|
+
commands = [],
|
|
127
146
|
} = {}) {
|
|
128
147
|
const named = paths !== null;
|
|
129
148
|
const dirty = (await dirtyPaths(root)).filter((path) => !path.startsWith(`${STATE_DIR}/`));
|
|
130
149
|
const scope = named ? parsePathList(paths) : dirty;
|
|
131
150
|
|
|
132
|
-
const graph = await loadGraph(root, { build });
|
|
151
|
+
const graph = await loadGraph(root, { build, task, embed: flags?.embed === true });
|
|
133
152
|
if (graph.error) return { error: graph.error };
|
|
134
153
|
if (graph.cold) return { cold: true };
|
|
135
154
|
const graphStale = graph.stale === true;
|
|
@@ -187,6 +206,15 @@ export async function assembleBrief({
|
|
|
187
206
|
})
|
|
188
207
|
: null;
|
|
189
208
|
const ranked = rankFiles({ files, symbols, edges, fts, bm25, embeddings, queryVector, task });
|
|
209
|
+
// An empty match is a different answer from a weak one: the ranking is then the graph
|
|
210
|
+
// hub, which is what a reader who asked about `zebracorn` must not read as a hit
|
|
211
|
+
// (review 0003 Q52).
|
|
212
|
+
const matched = ranked.filter((row) => (row.score ?? 0) > 0).length;
|
|
213
|
+
if (task.trim().length > 0 && matched === 0) {
|
|
214
|
+
packet.add('CAUTIONS', 'matched 0: no file or symbol mentions this task, so FILES is the graph hub rather than a match', {
|
|
215
|
+
why: ['an answer to a graph-shaped question is not an answer to this task'],
|
|
216
|
+
});
|
|
217
|
+
}
|
|
190
218
|
const rendered = renderMap(ranked, symbols, { level: 'names', budget: budget > 0 ? budget * MAP_FACTOR : 0 });
|
|
191
219
|
for (const line of rendered.text.split('\n')) {
|
|
192
220
|
packet.add('FILES', line, { why: ['ranked by the task words against the import graph'] });
|
|
@@ -219,13 +247,15 @@ export async function assembleBrief({
|
|
|
219
247
|
}
|
|
220
248
|
|
|
221
249
|
const likely = ranked.slice(0, LIKELY_FILES).map((row) => row.path);
|
|
222
|
-
const known = testEdges({ files, imports: edges.filter((edge) => edge.kind === 'import') });
|
|
223
250
|
const untested = [];
|
|
224
251
|
packet.add('TESTS', `the index knows direct importers; \`${CLI} tests <path>\` is the authority for multi-line and dynamic imports`, {
|
|
225
252
|
why: ['a cheap packet and an exact answer are different questions'],
|
|
226
253
|
});
|
|
227
254
|
for (const path of likely) {
|
|
228
|
-
|
|
255
|
+
// The packet's own edges, walked by the same function `tests` uses (Q77).
|
|
256
|
+
const found = coverage({ files, imports: edges.filter((edge) => edge.kind === 'import') }, [path], {
|
|
257
|
+
transitive: false,
|
|
258
|
+
});
|
|
229
259
|
if (found.length === 0) untested.push(path);
|
|
230
260
|
else packet.add('TESTS', `${path} -> ${found.join(' ')}`, { why: ['the test imports it, or sits beside it'] });
|
|
231
261
|
}
|
|
@@ -233,7 +263,10 @@ export async function assembleBrief({
|
|
|
233
263
|
const records = await decisionRecords(root);
|
|
234
264
|
const ctx = {
|
|
235
265
|
files: filePaths,
|
|
236
|
-
|
|
266
|
+
// The card's lines are shell commands (`npm run test`); a decision's Affects token is
|
|
267
|
+
// a command of this tool. Passing only the card's list made every `simt …` token
|
|
268
|
+
// unresolved — 47 false cautions on this repository (review 0003 Q52).
|
|
269
|
+
commands: new Set([...card.lines.map((line) => line.command), ...commands]),
|
|
237
270
|
roots: sourceRoots([...filePaths]),
|
|
238
271
|
};
|
|
239
272
|
const decisions = new Map();
|
package/src/ci/dependabot.mjs
CHANGED
|
@@ -40,61 +40,30 @@ import { join } from 'node:path';
|
|
|
40
40
|
import { fingerprint } from '../resolve/fingerprint.mjs';
|
|
41
41
|
import { isOwnRepo, WORKFLOWS_DIR } from './workflow.mjs';
|
|
42
42
|
import { CLI } from '../name.mjs';
|
|
43
|
+
// Q70: the Dependabot entries are the records' `dependabot` facet, not a table in this
|
|
44
|
+
// file. Adding an ecosystem is a new file under `ecosystems/`, not a row here.
|
|
45
|
+
import { dependabotEntries } from '../ecosystem/records.mjs';
|
|
43
46
|
|
|
44
47
|
/** The one file this module writes. */
|
|
45
48
|
export const DEPENDABOT_FILE = '.github/dependabot.yml';
|
|
46
49
|
|
|
47
50
|
/**
|
|
48
|
-
* fact -> the `package-ecosystem` value Dependabot documents for it.
|
|
51
|
+
* fact/stack -> the `package-ecosystem` value Dependabot documents for it.
|
|
49
52
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* about the estate rather than a code detail.
|
|
53
|
+
* Read from the ecosystem records (ADR-013: content is data, the engine is code), not
|
|
54
|
+
* written here. `dependabotEntries(root)` returns the rows in their declared order; `root`
|
|
55
|
+
* lets a repository's own `.simt/ecosystems/` supply an entry the shipped set does not have,
|
|
56
|
+
* so adding an ecosystem is a new file, not a row in a table the engine edits.
|
|
55
57
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* a dependency name, so a repo's package manager is either of those and adding
|
|
65
|
-
* `has-node-manifest` would be a third name for one observation - the exact
|
|
66
|
-
* duplication that table's own header rules out. `gradle` is the same shape:
|
|
67
|
-
* the stack tag already names the build file. Every other entry below is a
|
|
68
|
-
* real `has-*` fact.
|
|
69
|
-
*
|
|
70
|
-
* `has-build-system` is deliberately NOT mapped: Makefile, CMakeLists and
|
|
71
|
-
* meson.build are build files, not dependency manifests, and Dependabot has no
|
|
72
|
-
* ecosystem that reads them. Mapping it to `maven` would be a lie of exactly the
|
|
73
|
-
* kind the detection-driven ruling exists to prevent.
|
|
58
|
+
* The fact names are already in the fingerprint vocabulary and already carry evidence, so
|
|
59
|
+
* the records add no new observation vocabulary: each entry says which Dependabot value
|
|
60
|
+
* watches which observable manifest. Several facts map to one value on purpose — `has-pyproject`
|
|
61
|
+
* and `has-requirements` are two ways of declaring python dependencies and Dependabot reads
|
|
62
|
+
* both under `pip`; emitting two `pip` entries would be a duplicate the config rejects. A
|
|
63
|
+
* `stack` tag stands in where no fact exists (`node`, `gradle`). `has-build-system` is
|
|
64
|
+
* deliberately NOT mapped: Makefile, CMakeLists and meson.build are build files, not
|
|
65
|
+
* dependency manifests, and Dependabot has no ecosystem that reads them.
|
|
74
66
|
*/
|
|
75
|
-
const ECOSYSTEMS = [
|
|
76
|
-
{ stack: 'node', value: 'npm', manifest: 'package.json' },
|
|
77
|
-
{ stack: 'typescript', value: 'npm', manifest: 'package.json' },
|
|
78
|
-
{ fact: 'has-pyproject', value: 'pip', manifest: 'pyproject.toml' },
|
|
79
|
-
{ fact: 'has-requirements', value: 'pip', manifest: 'requirements.txt' },
|
|
80
|
-
{ fact: 'has-go-mod', value: 'gomod', manifest: 'go.mod' },
|
|
81
|
-
{ fact: 'has-cargo-toml', value: 'cargo', manifest: 'Cargo.toml' },
|
|
82
|
-
{ fact: 'has-gemfile', value: 'bundler', manifest: 'Gemfile' },
|
|
83
|
-
{ fact: 'has-composer', value: 'composer', manifest: 'composer.json' },
|
|
84
|
-
{ fact: 'has-maven-pom', value: 'maven', manifest: 'pom.xml' },
|
|
85
|
-
{ fact: 'has-dockerfile', value: 'docker', manifest: 'Dockerfile' },
|
|
86
|
-
{ fact: 'has-compose', value: 'docker-compose', manifest: 'compose file' },
|
|
87
|
-
{ fact: 'has-terraform', value: 'terraform', manifest: '*.tf' },
|
|
88
|
-
{ stack: 'gradle', value: 'gradle', manifest: 'build.gradle' },
|
|
89
|
-
{ fact: 'has-submodules', value: 'gitsubmodule', manifest: '.gitmodules' },
|
|
90
|
-
{ fact: 'has-nuget', value: 'nuget', manifest: '*.csproj' },
|
|
91
|
-
{ fact: 'has-pubspec', value: 'pub', manifest: 'pubspec.yaml' },
|
|
92
|
-
{ fact: 'has-swift-package', value: 'swift', manifest: 'Package.swift' },
|
|
93
|
-
{ fact: 'has-mix', value: 'mix', manifest: 'mix.exs' },
|
|
94
|
-
{ fact: 'has-uv-lock', value: 'uv', manifest: 'uv.lock' },
|
|
95
|
-
{ fact: 'has-bun-lock', value: 'bun', manifest: 'bun.lock' },
|
|
96
|
-
{ fact: 'has-devcontainer', value: 'devcontainers', manifest: 'devcontainer.json' },
|
|
97
|
-
];
|
|
98
67
|
|
|
99
68
|
/**
|
|
100
69
|
* The ecosystems this tree has a manifest for, in a stable order.
|
|
@@ -108,11 +77,11 @@ const ECOSYSTEMS = [
|
|
|
108
77
|
* @param {Iterable<string>} [stack] emitted stack tags
|
|
109
78
|
* @returns {{value: string, manifest: string}[]}
|
|
110
79
|
*/
|
|
111
|
-
export function ecosystems(facts, stack = []) {
|
|
80
|
+
export function ecosystems(facts, stack = [], root = null) {
|
|
112
81
|
const seenFacts = new Set(facts);
|
|
113
82
|
const seenStack = new Set(stack);
|
|
114
83
|
const out = [];
|
|
115
|
-
for (const row of
|
|
84
|
+
for (const row of dependabotEntries(root)) {
|
|
116
85
|
const found = row.fact ? seenFacts.has(row.fact) : seenStack.has(row.stack);
|
|
117
86
|
if (!found || out.some((e) => e.value === row.value)) continue;
|
|
118
87
|
out.push({ value: row.value, manifest: row.manifest });
|
|
@@ -174,8 +143,8 @@ function entry({ value }) {
|
|
|
174
143
|
* @param {{stack?: Iterable<string>, own?: boolean}} [options]
|
|
175
144
|
* @returns {string} YAML, LF-terminated
|
|
176
145
|
*/
|
|
177
|
-
export function renderDependabot(facts, { stack = [], own = false } = {}) {
|
|
178
|
-
const found = ecosystems(facts, stack);
|
|
146
|
+
export function renderDependabot(facts, { stack = [], own = false, root = null } = {}) {
|
|
147
|
+
const found = ecosystems(facts, stack, root);
|
|
179
148
|
const entries = [entry({ value: 'github-actions' }), ...found.map(entry)];
|
|
180
149
|
return `${header(own)}version: 2
|
|
181
150
|
updates:
|
|
@@ -221,18 +190,19 @@ export async function planDependabot(root) {
|
|
|
221
190
|
// "the file differs" instead of "the file differs from what this repo should get",
|
|
222
191
|
// and those are different questions when a manifest appeared since.
|
|
223
192
|
const { facts, stack } = await fingerprint(root);
|
|
224
|
-
const watched = ecosystems(facts, stack).map((e) => e.value);
|
|
193
|
+
const watched = ecosystems(facts, stack, root).map((e) => e.value);
|
|
225
194
|
// Carried on the plan so the writer renders from the same observation this
|
|
226
195
|
// comparison used. Re-detecting at write time would make `ci` and `check`
|
|
227
196
|
// disagree whenever a manifest appeared between the two calls, and the file
|
|
228
|
-
// would be written from facts nobody ever saw in the plan.
|
|
229
|
-
|
|
197
|
+
// would be written from facts nobody ever saw in the plan. `root` rides along so
|
|
198
|
+
// the writer renders the records a repository's own `.simt/ecosystems/` overrides.
|
|
199
|
+
const detection = { facts, stack, root };
|
|
230
200
|
|
|
231
201
|
if (exists) {
|
|
232
202
|
// Both sides of the comparison, for the same reason as the workflow plan: a reader
|
|
233
203
|
// told the file differs can be shown how, and re-rendering for a diff would let
|
|
234
204
|
// the two answers disagree.
|
|
235
|
-
const desired = renderDependabot(facts, { stack, own: true });
|
|
205
|
+
const desired = renderDependabot(facts, { stack, own: true, root });
|
|
236
206
|
const current = own ? text === desired : true;
|
|
237
207
|
return {
|
|
238
208
|
file: DEPENDABOT_FILE,
|
package/src/ci/workflow.mjs
CHANGED
|
@@ -167,7 +167,7 @@ function checkoutFull() {
|
|
|
167
167
|
* to. The own gate also varies the platforms and the Node versions, because this
|
|
168
168
|
* package is the one whose floor and whose ADR-012 claim have to hold on more than
|
|
169
169
|
* the minimum. */
|
|
170
|
-
function skeleton(name, steps, { matrix = null } = {}) {
|
|
170
|
+
function skeleton(name, steps, { matrix = null, timeoutMinutes = 5, gateMain = false } = {}) {
|
|
171
171
|
const axes = matrix
|
|
172
172
|
? Object.entries(matrix)
|
|
173
173
|
.map(([axis, values]) => ` ${axis}: [${values.map((v) => `'${v}'`).join(', ')}]`)
|
|
@@ -182,9 +182,22 @@ function skeleton(name, steps, { matrix = null } = {}) {
|
|
|
182
182
|
// it is written escaped, the same way the `concurrency` line below spells its own two.
|
|
183
183
|
const runner = matrix?.os ? '\${{ matrix.os }}' : 'ubuntu-latest';
|
|
184
184
|
const job = matrix?.node ? '\${{ matrix.os }} node \${{ matrix.node }}' : name;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
185
|
+
// Two trigger blocks, because the two copies answer different questions. An
|
|
186
|
+
// adopting repository merges through pull requests, which is where its gate runs.
|
|
187
|
+
// This repository merges locally, so a merge never opens a pull request: without a
|
|
188
|
+
// push trigger the commit that lands on `main` is the one no gate ever ran on
|
|
189
|
+
// (review 0003 Q57). Each block ships only in the copy whose claim it states.
|
|
190
|
+
const trigger = gateMain
|
|
191
|
+
? `# Pull requests ask whether a change may merge; a push to \`main\` asks whether
|
|
192
|
+
# what already merged is sound. This repository merges locally, so a merge never
|
|
193
|
+
# opens a pull request — without the second trigger the commit that lands on
|
|
194
|
+
# \`main\` is the one no gate ran on.
|
|
195
|
+
on:
|
|
196
|
+
pull_request:
|
|
197
|
+
push:
|
|
198
|
+
branches: [main]
|
|
199
|
+
`
|
|
200
|
+
: `# Pull requests only, and that is a decision rather than a default.
|
|
188
201
|
#
|
|
189
202
|
# A gate that runs on every push re-runs itself for every commit that pushes a
|
|
190
203
|
# branch forward, including the ones nobody asked it about, and its verdict has to
|
|
@@ -194,7 +207,10 @@ function skeleton(name, steps, { matrix = null } = {}) {
|
|
|
194
207
|
# on \`main\` is whatever CI the repository already has there.
|
|
195
208
|
on:
|
|
196
209
|
pull_request:
|
|
210
|
+
`;
|
|
211
|
+
return `name: ${name}
|
|
197
212
|
|
|
213
|
+
${trigger}
|
|
198
214
|
# Read-only is enough to check a repo, and a gate job does not need to write to it.
|
|
199
215
|
permissions:
|
|
200
216
|
contents: read
|
|
@@ -207,7 +223,7 @@ jobs:
|
|
|
207
223
|
check:
|
|
208
224
|
name: ${job}
|
|
209
225
|
runs-on: ${runner}
|
|
210
|
-
${strategy} timeout-minutes:
|
|
226
|
+
${strategy} timeout-minutes: ${timeoutMinutes}
|
|
211
227
|
steps:
|
|
212
228
|
${steps}`;
|
|
213
229
|
}
|
|
@@ -268,7 +284,7 @@ export function renderWorkflow({ own = false, npm = true, manager = null, versio
|
|
|
268
284
|
' - name: tests',
|
|
269
285
|
' run: npm test',
|
|
270
286
|
' - name: skill registry lint',
|
|
271
|
-
' run:
|
|
287
|
+
' run: npm run lint',
|
|
272
288
|
' - name: the gate copy in this repo is current',
|
|
273
289
|
// The drift this exception exists to prevent, checked where it would be caught.
|
|
274
290
|
// Without it a template change lands with the copy still describing the old
|
|
@@ -282,7 +298,14 @@ export function renderWorkflow({ own = false, npm = true, manager = null, versio
|
|
|
282
298
|
// sentence was false in the one repository that could have made it true, and no
|
|
283
299
|
// check read it - the package declares `os` for npm to enforce, and nothing here
|
|
284
300
|
// ever ran a test on a second platform.
|
|
285
|
-
|
|
301
|
+
// The slowest leg (macOS, full suite plus the registry lint) ran 4 min 28 s
|
|
302
|
+
// against a 5-minute timeout, so the timeout that was meant to bound a hang
|
|
303
|
+
// was about to fail a passing run instead. 15 leaves the hang bounded and the
|
|
304
|
+
// run alone; the sharding that would make it short again is Q92, not this row.
|
|
305
|
+
//
|
|
306
|
+
// `gateMain` because this repository merges locally: `pull_request` alone left
|
|
307
|
+
// the one commit that reached `main` ungated (review 0003 Q57).
|
|
308
|
+
{ matrix: { os: ['ubuntu-latest', 'macos-latest'], node: [floor, '24', '26'] }, timeoutMinutes: 15, gateMain: true },
|
|
286
309
|
)}\n`;
|
|
287
310
|
}
|
|
288
311
|
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The CLI proper, imported by `bin/simt.mjs` after the runtime floor is checked.
|
|
2
|
+
//
|
|
3
|
+
// It lives here rather than in `bin/` because a static import in the entry point is
|
|
4
|
+
// evaluated before the entry point's own code: the version check could not run first,
|
|
5
|
+
// and an old Node failed inside the module graph instead of saying which version it
|
|
6
|
+
// needed (review 0003 Q54).
|
|
7
|
+
|
|
8
|
+
import { parse, rawJsonFlag } from './argv.mjs';
|
|
9
|
+
import { EXIT, fail, jsonFailMode, jsonIndentDefault } from './output/index.mjs';
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const argv = process.argv.slice(2);
|
|
13
|
+
const parsed = parse(argv);
|
|
14
|
+
if (!parsed.ok) {
|
|
15
|
+
// The flags do not exist yet, so the JSON mode is read off the raw words:
|
|
16
|
+
// a parse failure under `--json` is a JSON document on stdout, not text on
|
|
17
|
+
// stderr with an empty stdout (Q11, ADR-070 ruling 4).
|
|
18
|
+
jsonFailMode(rawJsonFlag(argv));
|
|
19
|
+
process.exit(fail(parsed.error.message, parsed.error.code));
|
|
20
|
+
}
|
|
21
|
+
const { flags, positional } = parsed.value;
|
|
22
|
+
|
|
23
|
+
// Set before anything can fail: `no command given` under `--json` is a JSON
|
|
24
|
+
// document too, and a failure that prints as text with an empty stdout breaks
|
|
25
|
+
// the same ruling the parse failure above honours (Q11, ADR-070 ruling 4).
|
|
26
|
+
jsonIndentDefault(flags.pretty === true);
|
|
27
|
+
jsonFailMode(flags.json === true);
|
|
28
|
+
|
|
29
|
+
// No arguments at all is a usage error, not a silent help dump: an agent that
|
|
30
|
+
// ran the wrong thing should be told so rather than handed 40 lines of prose.
|
|
31
|
+
if (positional.length === 0 && !flags.help && !flags.version) {
|
|
32
|
+
process.exit(fail('no command given', EXIT.USAGE));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { dispatch } = await import('./commands/index.mjs');
|
|
36
|
+
const code = (await dispatch(argv)) ?? EXIT.OK;
|
|
37
|
+
// Assigned, not forced: a large document on stdout (a `--json --diff` over a
|
|
38
|
+
// skill-sized body exceeds the 64 KiB pipe buffer) is still draining when the
|
|
39
|
+
// command resolves, and `process.exit()` cuts it — exit 0 with half a JSON
|
|
40
|
+
// document on stdout. Letting the loop empty first makes the exit honest
|
|
41
|
+
// (plan 0003 R50; the S10 contract asserts the document parses).
|
|
42
|
+
process.exitCode = code;
|
|
43
|
+
} catch (error) {
|
|
44
|
+
process.exit(fail(error?.message ?? String(error), EXIT.FAIL));
|
|
45
|
+
}
|
package/src/commands/agent.mjs
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
// parse that can fail and no partial state, which is what makes this command
|
|
11
11
|
// shorter than `agent-hooks install`.
|
|
12
12
|
|
|
13
|
-
import { readFile } from 'node:fs/promises';
|
|
14
13
|
import { join } from 'node:path';
|
|
15
14
|
|
|
16
15
|
import { commitJournal } from '../journal.mjs';
|
|
@@ -27,9 +26,11 @@ import {
|
|
|
27
26
|
rendered,
|
|
28
27
|
writeAgentPlan,
|
|
29
28
|
} from '../agents/render.mjs';
|
|
29
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
30
|
+
import { readIfExists } from '../util/fs.mjs';
|
|
30
31
|
|
|
31
32
|
export async function agentsCommand(sub, args, flags) {
|
|
32
|
-
const root = flags
|
|
33
|
+
const root = commandRoot(flags, args);
|
|
33
34
|
if (sub === 'install') return installAgents(root, flags);
|
|
34
35
|
if (sub === 'status') return agentsStatus(root, flags);
|
|
35
36
|
return fail(`unknown agents subcommand ${sub}`, EXIT.USAGE, `run: ${CLI} agents status`);
|
|
@@ -102,7 +103,6 @@ async function installAgents(root, flags) {
|
|
|
102
103
|
'',
|
|
103
104
|
`PLAN nothing written; apply with: ${CLI} agents install --yes`,
|
|
104
105
|
].join('\n'),
|
|
105
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
106
106
|
);
|
|
107
107
|
// After the report: the report says what, the diff shows it (R50).
|
|
108
108
|
emitDiffs(diffs, flags);
|
|
@@ -135,7 +135,6 @@ async function installAgents(root, flags) {
|
|
|
135
135
|
'',
|
|
136
136
|
`WROTE ${written.length} agent file(s)`,
|
|
137
137
|
].join('\n'),
|
|
138
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
139
138
|
);
|
|
140
139
|
// After the report: the report says what, the diff shows it (R50).
|
|
141
140
|
emitDiffs(diffs, flags);
|
|
@@ -193,14 +192,6 @@ async function agentsStatus(root, flags) {
|
|
|
193
192
|
lines.push('', 'problems:');
|
|
194
193
|
for (const p of problems) lines.push(` ${p}`);
|
|
195
194
|
}
|
|
196
|
-
emit(lines.join('\n')
|
|
195
|
+
emit(lines.join('\n'));
|
|
197
196
|
return problems.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
198
197
|
}
|
|
199
|
-
|
|
200
|
-
async function readIfExists(path) {
|
|
201
|
-
try {
|
|
202
|
-
return await readFile(path, 'utf8');
|
|
203
|
-
} catch {
|
|
204
|
-
return null;
|
|
205
|
-
}
|
|
206
|
-
}
|