simt-dev 0.7.1 → 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 +36 -1
- package/README.md +169 -80
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- 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-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 +3 -1
- package/rules/mdc/intake-questions.mdc +8 -0
- package/src/advice/advice.mjs +3 -2
- package/src/agenthooks/render.mjs +86 -180
- package/src/agents/render.mjs +15 -18
- package/src/api.mjs +7 -5
- package/src/argv.mjs +19 -0
- package/src/bench/ablate.mjs +3 -4
- package/src/bench/adapters.json +155 -113
- package/src/bench/adapters.mjs +466 -0
- package/src/bench/arms.mjs +333 -0
- package/src/bench/grade.mjs +12 -26
- package/src/bench/graders.mjs +42 -0
- package/src/bench/mine.mjs +17 -535
- package/src/bench/probe-ledger.mjs +149 -0
- package/src/bench/probes.catalog.mjs +918 -0
- package/src/bench/probes.mjs +15 -1037
- package/src/bench/render.mjs +111 -0
- package/src/bench/report.mjs +116 -971
- package/src/bench/run.mjs +164 -881
- package/src/bench/variants.mjs +4 -5
- package/src/bench/workspace.mjs +160 -0
- package/src/brief/assemble.mjs +11 -13
- package/src/ci/dependabot.mjs +25 -55
- package/src/commands/agent.mjs +1 -9
- package/src/commands/agenthook.mjs +66 -35
- package/src/commands/bench.mjs +2 -2
- package/src/commands/ci.mjs +11 -13
- package/src/commands/docs.mjs +88 -0
- package/src/commands/gate.mjs +10 -13
- package/src/commands/harness.mjs +3 -3
- package/src/commands/help.mjs +44 -3
- package/src/commands/hooks.mjs +20 -20
- package/src/commands/ignore.mjs +11 -7
- package/src/commands/impact.mjs +4 -8
- package/src/commands/index.mjs +49 -9
- package/src/commands/init-scan.mjs +90 -0
- package/src/commands/init.mjs +122 -104
- package/src/commands/lease.mjs +67 -18
- package/src/commands/map.mjs +10 -5
- package/src/commands/mcp.mjs +2 -17
- package/src/commands/repoindex.mjs +8 -8
- package/src/commands/rules.mjs +8 -4
- package/src/commands/shared.mjs +12 -16
- package/src/commands/skills.mjs +5 -6
- package/src/commands/skillscompile.mjs +3 -3
- package/src/commands/skillsinstall.mjs +5 -4
- package/src/commands/slice.mjs +199 -64
- package/src/commands/spec.mjs +78 -62
- package/src/commands/status.mjs +24 -12
- package/src/commands/sync.mjs +14 -14
- package/src/commands/tests.mjs +12 -12
- package/src/commands/tokens.mjs +19 -24
- package/src/commands/upgrade.mjs +8 -4
- package/src/commands/why.mjs +6 -13
- package/src/compare/hooksstate.mjs +2 -6
- package/src/compare/learn.mjs +3 -1
- package/src/decisions/index.mjs +3 -2
- 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 +2 -1
- package/src/graph/spec.mjs +10 -8
- package/src/harness/records.mjs +143 -0
- package/src/hooks/capability.mjs +5 -3
- package/src/hooks/dispatcher.mjs +26 -32
- package/src/hooks/gates.mjs +12 -2
- package/src/hooks/resolve.mjs +2 -7
- package/src/hooks/where.mjs +2 -7
- package/src/identity.mjs +2 -11
- 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 +16 -11
- 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 +124 -17
- package/src/mcp/paginate.mjs +33 -0
- package/src/mcp/schemas.mjs +512 -0
- package/src/mcp/server.mjs +49 -9
- package/src/mcp/tools-facts.mjs +326 -0
- package/src/mcp/tools-read.mjs +221 -0
- package/src/mcp/tools.mjs +4 -1005
- package/src/{bench → measure}/corpus.mjs +1 -1
- package/src/measure/metrics.mjs +236 -0
- package/src/measure/records.mjs +211 -0
- 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/output/index.mjs +122 -31
- package/src/packs/manage.mjs +15 -9
- 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/manifest.mjs +10 -435
- 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 +16 -547
- package/src/projection/surfaces.mjs +43 -210
- package/src/projection/targets.mjs +5 -5
- package/src/repo-root.mjs +13 -1
- package/src/resolve/eval.mjs +2 -1
- package/src/resolve/fingerprint.mjs +0 -7
- package/src/resolve/resolve.mjs +1 -41
- package/src/review/critic.mjs +8 -12
- 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/{bench/process.mjs → sys/proc.mjs} +28 -5
- 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 +1 -1
- package/src/util/fs.mjs +20 -0
- package/src/util/sort.mjs +12 -0
- package/src/workspace.mjs +3 -1
- 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/src/bench/tasks.mjs +0 -36
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// One append-only event log for everything this tool observes about itself (Q73).
|
|
2
|
+
//
|
|
3
|
+
// The stores that record what happened - the evidence ledger, the lessons file, the lease
|
|
4
|
+
// file, the check cache - were five files with three version keys and no shared shape, so
|
|
5
|
+
// nothing could tail them and no UI could show a run. An event is one line with one
|
|
6
|
+
// envelope, appended with `O_APPEND` so two writers never rewrite each other's history.
|
|
7
|
+
//
|
|
8
|
+
// The log is the record; the ledger and the lessons file are projections of it. A reader
|
|
9
|
+
// that still reads the old file gets the same answer, because the projection is written to
|
|
10
|
+
// the same place it always was - but a line lost to rotation is recoverable here, and a
|
|
11
|
+
// consumer that wants to watch a run live has one file to follow.
|
|
12
|
+
//
|
|
13
|
+
// `O_APPEND` matters more than it looks: a lease write and a gate run in two processes can
|
|
14
|
+
// land at the same moment, and a read-modify-write of a shared log would drop one of them.
|
|
15
|
+
// That is the same defect the lease lock exists for, and an append does not need the lock.
|
|
16
|
+
|
|
17
|
+
import { randomBytes } from 'node:crypto';
|
|
18
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
import { localRel, readStateFile } from '../state.mjs';
|
|
22
|
+
|
|
23
|
+
/** Where the log lives: machine-local state, so no run changes the tree it describes. */
|
|
24
|
+
export const EVENTS_FILE = localRel('events.jsonl');
|
|
25
|
+
const EVENTS_LEGACY = '.simt/events.jsonl';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The closed set of kinds. A kind that is not here is a typo that would otherwise sit in
|
|
29
|
+
* the log forever, unread by every consumer and silently meaningless.
|
|
30
|
+
*/
|
|
31
|
+
export const EVENT_KINDS = Object.freeze([
|
|
32
|
+
'gate.started',
|
|
33
|
+
'gate.finished',
|
|
34
|
+
'policy.decided',
|
|
35
|
+
'lease.acquired',
|
|
36
|
+
'lease.released',
|
|
37
|
+
'lease.expired',
|
|
38
|
+
'slice.transitioned',
|
|
39
|
+
'write.committed',
|
|
40
|
+
'write.undone',
|
|
41
|
+
'lesson.captured',
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
/** Crockford base32, the alphabet ULID uses: no I, L, O or U, so a hand-copied id is unambiguous. */
|
|
45
|
+
const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A ULID: 48 bits of milliseconds, then 80 bits of randomness.
|
|
49
|
+
*
|
|
50
|
+
* Chosen over a UUID because it sorts by time as bytes, which is what makes a log
|
|
51
|
+
* appendable and a tail read meaningful without parsing timestamps.
|
|
52
|
+
*/
|
|
53
|
+
export function ulid(now = Date.now()) {
|
|
54
|
+
let time = '';
|
|
55
|
+
let left = now;
|
|
56
|
+
for (let i = 0; i < 10; i += 1) {
|
|
57
|
+
time = CROCKFORD[left % 32] + time;
|
|
58
|
+
left = Math.floor(left / 32);
|
|
59
|
+
}
|
|
60
|
+
const bytes = randomBytes(16);
|
|
61
|
+
let random = '';
|
|
62
|
+
for (let i = 0; i < 16; i += 1) random += CROCKFORD[bytes[i] % 32];
|
|
63
|
+
return `${time}${random}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Append one event, or throw.
|
|
68
|
+
*
|
|
69
|
+
* A caller that cannot record what it did has to hear about it: a swallowed failure here
|
|
70
|
+
* is a gate that ran and left no evidence, which is the state the ledger exists to make
|
|
71
|
+
* impossible.
|
|
72
|
+
*/
|
|
73
|
+
export async function appendEvent(root, { kind, session = null, harness = null, tree = null, subject = null, data = {} }) {
|
|
74
|
+
if (!EVENT_KINDS.includes(kind)) {
|
|
75
|
+
throw new Error(`unknown event kind: ${kind}`);
|
|
76
|
+
}
|
|
77
|
+
const event = {
|
|
78
|
+
v: 1,
|
|
79
|
+
id: ulid(),
|
|
80
|
+
ts: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
|
81
|
+
kind,
|
|
82
|
+
session,
|
|
83
|
+
harness,
|
|
84
|
+
tree,
|
|
85
|
+
subject,
|
|
86
|
+
data,
|
|
87
|
+
};
|
|
88
|
+
const path = join(root, EVENTS_FILE);
|
|
89
|
+
await mkdir(dirname(path), { recursive: true });
|
|
90
|
+
await appendFile(path, `${JSON.stringify(event)}\n`);
|
|
91
|
+
return event;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Best-effort append for a caller whose primary job must not fail on a log write.
|
|
96
|
+
*
|
|
97
|
+
* Used by `undo` and by lesson capture: the work those commands do is already done, and
|
|
98
|
+
* throwing afterwards would report a failure the user cannot act on. The error is returned
|
|
99
|
+
* rather than swallowed so a caller that wants to surface it can.
|
|
100
|
+
*/
|
|
101
|
+
export async function noteEvent(root, event) {
|
|
102
|
+
try {
|
|
103
|
+
return { event: await appendEvent(root, event), error: null };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { event: null, error: error.message };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every well-formed event, oldest first; damaged lines are reported, never hidden. */
|
|
110
|
+
export async function readEvents(root, { limit = 0 } = {}) {
|
|
111
|
+
const { text } = await readStateFile(root, EVENTS_FILE, [EVENTS_LEGACY]);
|
|
112
|
+
if (text === null) return { events: [], errors: [] };
|
|
113
|
+
const events = [];
|
|
114
|
+
const errors = [];
|
|
115
|
+
for (const [i, raw] of text.split('\n').entries()) {
|
|
116
|
+
if (raw.trim() === '') continue;
|
|
117
|
+
try {
|
|
118
|
+
const event = JSON.parse(raw);
|
|
119
|
+
if (typeof event?.id !== 'string' || typeof event?.kind !== 'string') {
|
|
120
|
+
errors.push(`line ${i + 1} has no id or kind`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
events.push(event);
|
|
124
|
+
} catch (error) {
|
|
125
|
+
errors.push(`line ${i + 1} does not parse (${error.message})`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { events: limit > 0 ? events.slice(-limit) : events, errors };
|
|
129
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// The aggregate behind bare `simt status` (review 0003 Q82).
|
|
2
|
+
//
|
|
3
|
+
// `status --line` answers one question in milliseconds: the gate state as of the last
|
|
4
|
+
// check. Bare `status` was a usage error, and its comment justified that by claiming
|
|
5
|
+
// `plan` without a subcommand behaved the same way - `plan` exits 0 and prints its
|
|
6
|
+
// tracker. The question bare `status` was refusing is the one a UI's home view and a
|
|
7
|
+
// harness status line ask: what is the state of this repository.
|
|
8
|
+
//
|
|
9
|
+
// Every slot is read through the module that owns it - the check cache, hook routing,
|
|
10
|
+
// the agent-hook table, the ledger, the lease file, the plan tracker, the journal -
|
|
11
|
+
// because a second implementation of any of them is a second answer to the same
|
|
12
|
+
// question, and the quiet one is the one a reader acts on. A slot that cannot answer
|
|
13
|
+
// carries its error and leaves the rest of the snapshot intact: an unreadable manifest
|
|
14
|
+
// must not blind a caller to the evidence and the leases that did answer.
|
|
15
|
+
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { adapters, hookPath, installedIn } from './agenthooks/render.mjs';
|
|
20
|
+
import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
|
|
21
|
+
import { repoContext } from './context.mjs';
|
|
22
|
+
import { listJournalSets } from './journal.mjs';
|
|
23
|
+
import { activeLeases, readLeases } from './lease.mjs';
|
|
24
|
+
import { hasTestEvidence, readLedger } from './ledger/index.mjs';
|
|
25
|
+
import { CLI } from './name.mjs';
|
|
26
|
+
import { planStatus } from './plan/index.mjs';
|
|
27
|
+
import { currentHead, readCheckCache, renderStatusLine } from './status.mjs';
|
|
28
|
+
import { gitText } from './sys/git.mjs';
|
|
29
|
+
|
|
30
|
+
/** One slot's failure is that slot's answer, never the snapshot's. */
|
|
31
|
+
async function slot(compute) {
|
|
32
|
+
try {
|
|
33
|
+
return await compute();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return { error: error.message };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function checkSlot(root) {
|
|
40
|
+
const record = await readCheckCache(root);
|
|
41
|
+
if (record === null) return null;
|
|
42
|
+
const head = await currentHead(root);
|
|
43
|
+
const stale = head !== null && record.head !== null && head !== record.head;
|
|
44
|
+
return {
|
|
45
|
+
at: record.at,
|
|
46
|
+
head: record.head,
|
|
47
|
+
stale,
|
|
48
|
+
line: renderStatusLine(record, stale),
|
|
49
|
+
rulesOk: record.rulesOk,
|
|
50
|
+
hooksOk: record.hooksOk,
|
|
51
|
+
gatesRun: record.gatesRun,
|
|
52
|
+
gatesTotal: record.gatesTotal,
|
|
53
|
+
blocking: record.blocking,
|
|
54
|
+
warnings: record.warnings,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function gitHooksSlot(root) {
|
|
59
|
+
const { manifest, exists, error } = await repoContext(root).manifest();
|
|
60
|
+
if (error) return { error };
|
|
61
|
+
const state = await readHooksState(root, { declaresGate: (event) => wantsShim(manifest, exists, event) });
|
|
62
|
+
return {
|
|
63
|
+
hooksPath: state.current,
|
|
64
|
+
liveDir: state.liveDir,
|
|
65
|
+
events: state.installed.map((i) => ({ event: i.event, present: i.present, stale: i.stale })),
|
|
66
|
+
foreign: state.foreign.length,
|
|
67
|
+
gates: exists ? { preCommit: manifest?.gates?.preCommit ?? [], prePush: manifest?.gates?.prePush ?? [] } : null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Which harnesses will decide a tool call.
|
|
73
|
+
*
|
|
74
|
+
* The summary, not the report: `agent-hooks status` owns the detail, including whether a
|
|
75
|
+
* file drifted from what `install` would write and whether the prompt event is opted
|
|
76
|
+
* into. What is shared is the definition of "installed" - `installedIn` reads the same
|
|
77
|
+
* document the hook runs from, so this slot cannot say present about an entry that
|
|
78
|
+
* `install` did not write.
|
|
79
|
+
*/
|
|
80
|
+
async function agentHooksSlot(root) {
|
|
81
|
+
const harnesses = [];
|
|
82
|
+
for (const row of adapters()) {
|
|
83
|
+
const path = hookPath(row.id);
|
|
84
|
+
let existing = null;
|
|
85
|
+
try {
|
|
86
|
+
existing = await readFile(join(root, path), 'utf8');
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error.code !== 'ENOENT') throw error;
|
|
89
|
+
}
|
|
90
|
+
if (existing === null) {
|
|
91
|
+
harnesses.push({ id: row.id, path, state: 'absent' });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (row.file) {
|
|
95
|
+
harnesses.push({ id: row.id, path, state: 'present' });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
let config;
|
|
99
|
+
try {
|
|
100
|
+
config = JSON.parse(existing);
|
|
101
|
+
} catch {
|
|
102
|
+
harnesses.push({ id: row.id, path, state: 'unreadable' });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
106
|
+
harnesses.push({ id: row.id, path, state: 'unreadable' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
harnesses.push({ id: row.id, path, state: installedIn(row, config).length > 0 ? 'present' : 'absent' });
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
wired: harnesses.filter((h) => h.state === 'present').length,
|
|
113
|
+
total: harnesses.length,
|
|
114
|
+
harnesses,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function evidenceSlot(root) {
|
|
119
|
+
const { entries, errors } = await readLedger(root);
|
|
120
|
+
let tree = null;
|
|
121
|
+
try {
|
|
122
|
+
tree = await gitText(root, ['rev-parse', 'HEAD^{tree}']);
|
|
123
|
+
} catch {
|
|
124
|
+
// No repository, no commits: there is no tree to have evidence for, and `null`
|
|
125
|
+
// says that rather than \"no evidence\", which would read as a missing run.
|
|
126
|
+
tree = null;
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
entries: entries.length,
|
|
130
|
+
errors: errors.length,
|
|
131
|
+
headTree: tree,
|
|
132
|
+
testsRecorded: tree === null ? null : hasTestEvidence(entries, tree),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function leasesSlot(root) {
|
|
137
|
+
const { leases, error } = await readLeases(root);
|
|
138
|
+
if (error) return { error };
|
|
139
|
+
const active = activeLeases({ leases });
|
|
140
|
+
return { active: active.length, paths: active.map((lease) => lease.path) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function slicesSlot(root) {
|
|
144
|
+
const status = await planStatus(root);
|
|
145
|
+
if (status.error) return { error: status.error };
|
|
146
|
+
return { ready: status.ready, totals: status.totals };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function journalSlot(root) {
|
|
150
|
+
const sets = await listJournalSets(root);
|
|
151
|
+
return { sets: sets.length, last: sets[0] ?? null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The repository's state in one document.
|
|
156
|
+
*
|
|
157
|
+
* `ok` is the gate's verdict, not the snapshot's: a slot that failed to read is a fact
|
|
158
|
+
* about this repository (unreadable state is a real condition), and folding it into
|
|
159
|
+
* `ok` would turn a stale manifest into a failing gate no `sync` can fix.
|
|
160
|
+
*/
|
|
161
|
+
export async function statusSnapshot(root) {
|
|
162
|
+
const [check, gitHooks, agentHooks, evidence, leases, slices, journal] = await Promise.all([
|
|
163
|
+
slot(() => checkSlot(root)),
|
|
164
|
+
slot(() => gitHooksSlot(root)),
|
|
165
|
+
slot(() => agentHooksSlot(root)),
|
|
166
|
+
slot(() => evidenceSlot(root)),
|
|
167
|
+
slot(() => leasesSlot(root)),
|
|
168
|
+
slot(() => slicesSlot(root)),
|
|
169
|
+
slot(() => journalSlot(root)),
|
|
170
|
+
]);
|
|
171
|
+
return {
|
|
172
|
+
ok: (check?.blocking ?? 0) === 0,
|
|
173
|
+
command: 'status',
|
|
174
|
+
root,
|
|
175
|
+
at: Date.now(),
|
|
176
|
+
head: await currentHead(root),
|
|
177
|
+
check,
|
|
178
|
+
gitHooks,
|
|
179
|
+
agentHooks,
|
|
180
|
+
evidence,
|
|
181
|
+
leases,
|
|
182
|
+
slices,
|
|
183
|
+
journal,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function unavailable(label, slot) {
|
|
188
|
+
return `${label.padEnd(9)}${slot.error}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The same snapshot, read as a person: one line per slot, and a pointer for the detail. */
|
|
192
|
+
export function renderSnapshot(snapshot) {
|
|
193
|
+
const lines = [`repo ${snapshot.root}`];
|
|
194
|
+
if (snapshot.head !== null) lines.push(`head ${snapshot.head}`);
|
|
195
|
+
|
|
196
|
+
if (snapshot.check === null) lines.push(`check no cache - run ${CLI} check`);
|
|
197
|
+
else if (snapshot.check.error) lines.push(unavailable('check', snapshot.check));
|
|
198
|
+
else lines.push(`check ${snapshot.check.line}`);
|
|
199
|
+
|
|
200
|
+
const hooks = snapshot.gitHooks;
|
|
201
|
+
if (hooks.error) lines.push(unavailable('githooks', hooks));
|
|
202
|
+
else {
|
|
203
|
+
const wired = hooks.events.filter((e) => e.present && !e.stale).length;
|
|
204
|
+
const foreign = hooks.foreign > 0 ? `, ${hooks.foreign} foreign hook(s)` : '';
|
|
205
|
+
lines.push(`githooks ${wired}/${hooks.events.length} events wired${foreign}${hooks.hooksPath === null ? ` (git default: ${hooks.liveDir})` : ''}`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const agent = snapshot.agentHooks;
|
|
209
|
+
if (agent.error) lines.push(unavailable('agenthooks', agent));
|
|
210
|
+
else lines.push(`agenthooks ${agent.wired}/${agent.total} harnesses wired - ${CLI} agent-hooks status`);
|
|
211
|
+
|
|
212
|
+
const evidence = snapshot.evidence;
|
|
213
|
+
if (evidence.error) lines.push(unavailable('evidence', evidence));
|
|
214
|
+
else if (evidence.testsRecorded === null) lines.push(`evidence ${evidence.entries} run(s), no head tree to compare`);
|
|
215
|
+
else lines.push(`evidence ${evidence.entries} run(s), ${evidence.testsRecorded ? 'a test run is recorded for this tree' : 'no test run for this tree'}`);
|
|
216
|
+
|
|
217
|
+
const leases = snapshot.leases;
|
|
218
|
+
if (leases.error) lines.push(unavailable('leases', leases));
|
|
219
|
+
else lines.push(`leases ${leases.active} active${leases.paths.length > 0 ? `: ${leases.paths.join(', ')}` : ''}`);
|
|
220
|
+
|
|
221
|
+
const slices = snapshot.slices;
|
|
222
|
+
if (slices.error) lines.push(unavailable('slices', slices));
|
|
223
|
+
else {
|
|
224
|
+
const counts = Object.entries(slices.totals).map(([state, count]) => `${count} ${state.toLowerCase()}`);
|
|
225
|
+
lines.push(`slices ${slices.ready} ready${counts.length > 0 ? ` - ${counts.join(', ')}` : ''}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const journal = snapshot.journal;
|
|
229
|
+
if (journal.error) lines.push(unavailable('journal', journal));
|
|
230
|
+
else lines.push(`journal ${journal.sets} set(s)${journal.last === null ? '' : `, last ${journal.last}`}`);
|
|
231
|
+
|
|
232
|
+
return lines.join('\n');
|
|
233
|
+
}
|
package/src/status.mjs
CHANGED
|
@@ -25,15 +25,12 @@
|
|
|
25
25
|
// booleans are stored, never findings, so a huge gate still leaves a
|
|
26
26
|
// ~200-byte file and a killed `check` never leaves half a document.
|
|
27
27
|
|
|
28
|
-
import { execFile } from 'node:child_process';
|
|
29
28
|
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
30
29
|
import { join } from 'node:path';
|
|
31
|
-
import { promisify } from 'node:util';
|
|
32
30
|
|
|
33
31
|
import { readHooksState, wantsShim } from './compare/hooksstate.mjs';
|
|
34
32
|
import { LOCAL_DIR } from './state.mjs';
|
|
35
|
-
|
|
36
|
-
const exec = promisify(execFile);
|
|
33
|
+
import { gitText } from './sys/git.mjs';
|
|
37
34
|
|
|
38
35
|
const CACHE_FILE = join(LOCAL_DIR, 'check.json');
|
|
39
36
|
const CACHE_FILE_LEGACY = '.simt/check.json';
|
|
@@ -47,8 +44,7 @@ const CACHE_VERSION = 1;
|
|
|
47
44
|
*/
|
|
48
45
|
export async function currentHead(root) {
|
|
49
46
|
try {
|
|
50
|
-
|
|
51
|
-
return stdout.trim() || null;
|
|
47
|
+
return await gitText(root, ['rev-parse', 'HEAD']);
|
|
52
48
|
} catch {
|
|
53
49
|
return null;
|
|
54
50
|
}
|
package/src/sys/git.mjs
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The one git runner (Q72).
|
|
2
|
+
//
|
|
3
|
+
// Thirty modules imported `child_process` and grew their own `git()`: some threw, some
|
|
4
|
+
// returned null, some trimmed, some split on NUL, and each of them decided for itself
|
|
5
|
+
// whether `core.quotePath` was the caller's problem. The copies disagreed in ways that
|
|
6
|
+
// only showed up on a path with a non-ASCII byte in it.
|
|
7
|
+
//
|
|
8
|
+
// Three verbs, and the difference between them is the return type, not the flags:
|
|
9
|
+
// `git` answers the raw stdout, `gitText` answers it trimmed or null, and `gitPaths`
|
|
10
|
+
// answers NUL-separated paths. `allowFailure` is explicit because "this repository is not
|
|
11
|
+
// a git repository" is sometimes an answer and sometimes an error, and only the caller
|
|
12
|
+
// knows which.
|
|
13
|
+
|
|
14
|
+
import { execFile } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
/** How much stdout one git call may buffer before the call is refused. */
|
|
17
|
+
const GIT_MAX_BUFFER = 32 << 20;
|
|
18
|
+
|
|
19
|
+
/** A failed git call, carrying the arguments and git's own stderr.
|
|
20
|
+
* `stdout` is kept too: a call that fails part-way (a `diff` in a tree with no HEAD)
|
|
21
|
+
* can still have printed, and a caller that salvages the partial output must not lose
|
|
22
|
+
* it to the wrap. */
|
|
23
|
+
class GitError extends Error {
|
|
24
|
+
constructor(args, cause) {
|
|
25
|
+
const stderr = String(cause?.stderr ?? '').trim();
|
|
26
|
+
super(`git ${args.join(' ')} failed${stderr ? `: ${stderr}` : ''}`);
|
|
27
|
+
this.name = 'GitError';
|
|
28
|
+
this.args = args;
|
|
29
|
+
this.stderr = stderr;
|
|
30
|
+
this.stdout = cause?.stdout;
|
|
31
|
+
this.code = cause?.code;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run git in `root` and answer stdout.
|
|
37
|
+
*
|
|
38
|
+
* `core.quotePath=false` is set on every call so a path with a non-ASCII byte comes back
|
|
39
|
+
* as itself; the copies that relied on a caller remembering `-z` did not.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} root
|
|
42
|
+
* @param {string[]} args
|
|
43
|
+
* @param {{allowFailure?: boolean, maxBuffer?: number, input?: string, env?: NodeJS.ProcessEnv}} [options]
|
|
44
|
+
* @returns {Promise<string|null>} stdout, or null when `allowFailure` and the call failed
|
|
45
|
+
*/
|
|
46
|
+
export function git(
|
|
47
|
+
root,
|
|
48
|
+
args,
|
|
49
|
+
{ allowFailure = false, maxBuffer = GIT_MAX_BUFFER, input, env = process.env } = {},
|
|
50
|
+
) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const child = execFile(
|
|
53
|
+
'git',
|
|
54
|
+
['-c', 'core.quotePath=false', ...args],
|
|
55
|
+
{ cwd: root, encoding: 'utf8', maxBuffer, env },
|
|
56
|
+
(error, stdout, stderr) => {
|
|
57
|
+
if (error) {
|
|
58
|
+
if (allowFailure) resolve(null);
|
|
59
|
+
else reject(new GitError(args, Object.assign(error, { stderr })));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
resolve(stdout);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
// `execFile` has no `input` option and leaves stdin a pipe: a child like `hash-object
|
|
66
|
+
// --stdin` waits for an EOF that never comes, so the call hangs rather than fails.
|
|
67
|
+
if (input !== undefined) child.stdin.end(input);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** stdout trimmed, or null when `allowFailure` and the call failed. */
|
|
72
|
+
export async function gitText(root, args, options = {}) {
|
|
73
|
+
const stdout = await git(root, args, options);
|
|
74
|
+
return stdout === null ? null : stdout.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** NUL-separated stdout split into paths. `-z` is added here, so a caller cannot forget it. */
|
|
78
|
+
export async function gitPaths(root, args, options = {}) {
|
|
79
|
+
const stdout = await git(root, [...args, '-z'], options);
|
|
80
|
+
if (stdout === null) return null;
|
|
81
|
+
return stdout.split('\0').filter(Boolean);
|
|
82
|
+
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
|
-
// One bounded child process
|
|
1
|
+
// One bounded child process and the environment a child inherits, for everything
|
|
2
|
+
// that runs one (review 0003 Q47, Q72).
|
|
2
3
|
//
|
|
3
|
-
// `child.kill('SIGKILL')` kills the process the
|
|
4
|
+
// `child.kill('SIGKILL')` kills the process the caller started and leaves its
|
|
4
5
|
// grandchildren running: an adapter that ran `(sleep 15 &); sleep 60` kept the pipe open
|
|
5
|
-
// and the
|
|
6
|
+
// and the caller waited 62.6 s against a 2 s timeout, then graded the result. A detached
|
|
6
7
|
// child is its own process group, so the deadline kills the group, destroys the streams
|
|
7
8
|
// and settles. A run cannot outlive its own deadline, whatever it spawned.
|
|
8
9
|
//
|
|
9
|
-
// Output is bounded so a runaway
|
|
10
|
-
// oldest chunk is dropped and `truncated` says so, rather than the spawn failing.
|
|
10
|
+
// Output is bounded so a runaway child cannot decide the caller's memory: past the cap
|
|
11
|
+
// the oldest chunk is dropped and `truncated` says so, rather than the spawn failing.
|
|
12
|
+
//
|
|
13
|
+
// This module used to live in `src/bench/` because the bench was its only caller. The
|
|
14
|
+
// bench is not the only thing that runs a child it does not trust, and a second copy of
|
|
15
|
+
// the deadline logic is a second way for a run to outlive its deadline.
|
|
11
16
|
|
|
12
17
|
import { spawn } from 'node:child_process';
|
|
13
18
|
|
|
@@ -126,3 +131,21 @@ export function spawnBounded(
|
|
|
126
131
|
child.stdin?.end(input ?? undefined);
|
|
127
132
|
});
|
|
128
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* What a spawned child is allowed to inherit from the operator's shell.
|
|
137
|
+
*
|
|
138
|
+
* Three bench modules grew this identically, and the differences between what a child
|
|
139
|
+
* needs and what the shell carries are the whole point: `SIMT_DEV_WORKSPACE` is forced
|
|
140
|
+
* off so a child cannot resolve THIS repository's workspace, and the `NODE_TEST*`
|
|
141
|
+
* variables are dropped because they steer `node --test` at a test directory the child
|
|
142
|
+
* does not have. Nothing here names a credential or a state directory: those come from
|
|
143
|
+
* the caller's own `extra`.
|
|
144
|
+
*/
|
|
145
|
+
export function sanitizedEnv(extra = {}) {
|
|
146
|
+
const env = { ...process.env, SIMT_DEV_WORKSPACE: 'off', ...extra };
|
|
147
|
+
for (const key of Object.keys(env)) {
|
|
148
|
+
if (key.startsWith('NODE_TEST')) delete env[key];
|
|
149
|
+
}
|
|
150
|
+
return env;
|
|
151
|
+
}
|