simt-dev 0.6.3 → 0.7.1
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 +162 -841
- package/README.md +21 -135
- package/agents/simt-agent.md +84 -0
- package/bin/simt.mjs +19 -55
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-014-workspace-is-configuration.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-099-the-release-verifies-served-bytes-and-the-formula-mirrors-them.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/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/package.json +13 -3
- package/rules/core.md +9 -2
- package/rules/mdc/slice-commit.mdc +57 -4
- package/rules/mdc/test-gate.mdc +2 -0
- package/rules/stacks/android.md +3 -3
- package/rules/stacks/connectiq.md +2 -2
- package/rules/stacks/dart.md +1 -1
- package/rules/stacks/deno.md +1 -1
- package/rules/stacks/dotnet.md +1 -1
- package/rules/stacks/elixir.md +1 -1
- package/rules/stacks/go.md +1 -1
- package/rules/stacks/jvm.md +3 -2
- package/rules/stacks/node.md +2 -2
- package/rules/stacks/python.md +7 -6
- package/rules/stacks/ruby.md +1 -1
- package/rules/stacks/rust.md +1 -1
- package/rules/stacks/swift.md +1 -1
- package/rules/stacks/typescript.md +2 -2
- package/rules/stacks/uv.md +3 -2
- package/skills/simt-commands/SKILL.md +70 -0
- package/src/advice/advice.mjs +7 -3
- package/src/agenthooks/decide.mjs +141 -9
- package/src/agenthooks/opencode.mjs +72 -0
- package/src/agenthooks/prompt.mjs +10 -3
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/api.mjs +29 -0
- package/src/argv.mjs +37 -3
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/adapters.json +16 -0
- package/src/bench/corpus.mjs +150 -0
- package/src/bench/grade.mjs +23 -49
- package/src/bench/mine.mjs +247 -36
- package/src/bench/probes.mjs +104 -68
- package/src/bench/process.mjs +128 -0
- package/src/bench/report.mjs +260 -84
- package/src/bench/run.mjs +251 -150
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +44 -3
- package/src/ci/workflow.mjs +46 -14
- package/src/cli.mjs +45 -0
- package/src/commands/agent.mjs +205 -0
- package/src/commands/agenthook.mjs +113 -20
- package/src/commands/bench.mjs +192 -18
- package/src/commands/brief.mjs +5 -4
- package/src/commands/ci.mjs +2 -1
- package/src/commands/compare.mjs +50 -49
- package/src/commands/completions.mjs +2 -6
- package/src/commands/decide.mjs +10 -9
- package/src/commands/distill.mjs +4 -3
- package/src/commands/doctor.mjs +11 -7
- package/src/commands/evidence.mjs +19 -12
- package/src/commands/explain.mjs +0 -1
- package/src/commands/gate.mjs +98 -28
- package/src/commands/graph.mjs +20 -8
- package/src/commands/harness.mjs +3 -2
- package/src/commands/help.mjs +160 -31
- package/src/commands/hooks.mjs +163 -50
- package/src/commands/ignore.mjs +2 -1
- package/src/commands/impact.mjs +8 -9
- package/src/commands/index.mjs +278 -180
- package/src/commands/init.mjs +171 -38
- package/src/commands/lease.mjs +206 -39
- package/src/commands/lessons.mjs +72 -15
- package/src/commands/local.mjs +3 -3
- package/src/commands/map.mjs +47 -27
- package/src/commands/mcp.mjs +0 -1
- package/src/commands/pack.mjs +9 -6
- package/src/commands/plan.mjs +6 -5
- package/src/commands/plugin.mjs +3 -3
- package/src/commands/prbody.mjs +3 -3
- package/src/commands/repoindex.mjs +5 -3
- package/src/commands/review.mjs +2 -2
- package/src/commands/rules.mjs +124 -33
- package/src/commands/shared.mjs +2 -3
- package/src/commands/skills.mjs +82 -45
- package/src/commands/skillscompile.mjs +12 -12
- package/src/commands/skillsinstall.mjs +14 -2
- package/src/commands/slice.mjs +396 -26
- package/src/commands/spec.mjs +151 -0
- package/src/commands/status.mjs +4 -7
- package/src/commands/sync.mjs +50 -33
- package/src/commands/tests.mjs +11 -11
- package/src/commands/tokens.mjs +2 -2
- package/src/commands/undo.mjs +29 -31
- package/src/commands/upgrade.mjs +40 -4
- package/src/commands/why.mjs +19 -12
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -3
- package/src/completions.mjs +5 -0
- package/src/context.mjs +64 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index-doc.mjs +46 -0
- package/src/decisions/index.mjs +109 -11
- package/src/diff.mjs +1 -1
- package/src/distill/distill.mjs +17 -2
- package/src/distill/engine.mjs +5 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +42 -20
- package/src/hooks/capability.mjs +17 -4
- package/src/hooks/dispatcher.mjs +513 -36
- package/src/hooks/gates.mjs +0 -43
- package/src/hooks/resolve.mjs +11 -10
- package/src/hooks/shim.mjs +17 -9
- package/src/identity.mjs +96 -15
- package/src/index/extract.mjs +192 -11
- package/src/index/persist.mjs +307 -47
- package/src/index/store.mjs +263 -60
- package/src/index/tests.mjs +6 -35
- package/src/journal.mjs +372 -84
- package/src/kernel/card.mjs +37 -12
- package/src/kernel/checks.mjs +44 -9
- package/src/kernel/variants.mjs +27 -16
- package/src/lease.mjs +97 -15
- package/src/ledger/index.mjs +120 -25
- package/src/local/client.mjs +4 -6
- package/src/locate.mjs +33 -9
- package/src/manifest/schema.mjs +213 -0
- package/src/mcp/resources.mjs +12 -13
- package/src/mcp/server.mjs +151 -38
- package/src/mcp/tools.mjs +90 -16
- package/src/measure/session.mjs +3 -2
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- package/src/node-version.mjs +37 -0
- package/src/output/index.mjs +111 -3
- package/src/packs/manage.mjs +17 -3
- package/src/packs.mjs +20 -2
- package/src/plan/index.mjs +31 -8
- package/src/plan/template.mjs +27 -5
- package/src/plugins.mjs +6 -2
- package/src/prbody.mjs +7 -5
- package/src/projection/baselines.mjs +1 -1
- package/src/projection/disclosure.mjs +2 -2
- package/src/projection/loaders.mjs +41 -1
- package/src/projection/lock.mjs +31 -14
- package/src/projection/manifest.mjs +89 -53
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +58 -6
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +88 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +24 -15
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +6 -3
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +8 -0
- package/src/tsconfig.mjs +39 -99
- package/src/workspace.mjs +55 -10
- package/templates/plan/tracker.json +1 -1
- package/docs/decisions/README.md +0 -591
package/src/commands/lease.mjs
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
// `simt lease [<paths>...]`: claim files for one session, or read the claims (R54).
|
|
2
|
+
// `lease release` and `lease renew` end and extend the holder's own claims: the
|
|
3
|
+
// denial tells a blocked session to ask the holder to release the path, so the
|
|
4
|
+
// holder needs a command that does it (review 0003 Q20).
|
|
2
5
|
//
|
|
3
6
|
// The claim is the half of ADR-101 a session does by hand: `lease src/a.mjs --session
|
|
4
7
|
// alpha` records that alpha is working on the path until a TTL expires, the pre-edit
|
|
@@ -12,15 +15,29 @@
|
|
|
12
15
|
// (the hook fails closed on that same file, so writing over it would erase the very
|
|
13
16
|
// claims the hook is about to enforce while telling the author it fixed something).
|
|
14
17
|
|
|
15
|
-
import { EXIT, emit, emitJson, fail, isJson, writeMode } from '../output/index.mjs';
|
|
16
|
-
import {
|
|
18
|
+
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_TTL_SECONDS,
|
|
21
|
+
activeLeases,
|
|
22
|
+
canonicalLeasePath,
|
|
23
|
+
readLeases,
|
|
24
|
+
withLeaseLock,
|
|
25
|
+
writeLeases,
|
|
26
|
+
} from '../lease.mjs';
|
|
27
|
+
import { coreIgnoreCase } from '../repo-root.mjs';
|
|
17
28
|
import { SESSION_ENV } from '../agenthooks/policy.mjs';
|
|
18
29
|
import { CLI } from '../name.mjs';
|
|
30
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
19
31
|
|
|
20
32
|
const ISO = (ms) => new Date(ms).toISOString();
|
|
21
33
|
|
|
22
34
|
export async function leaseCommand(args, flags) {
|
|
23
|
-
const root = flags
|
|
35
|
+
const root = commandRoot(flags);
|
|
36
|
+
const [sub, ...rest] = args;
|
|
37
|
+
// The denial tells a blocked session to ask the holder to release the path,
|
|
38
|
+
// so releasing is a subcommand, not a missing flag (review 0003 Q20).
|
|
39
|
+
if (sub === 'release') return releaseLeases(root, rest, flags);
|
|
40
|
+
if (sub === 'renew') return renewLeases(root, rest, flags);
|
|
24
41
|
|
|
25
42
|
// A session or a TTL with no paths is a claim about nothing; the parser would
|
|
26
43
|
// otherwise hand back a list the caller never asked to see.
|
|
@@ -39,11 +56,17 @@ export async function leaseCommand(args, flags) {
|
|
|
39
56
|
if (!Number.isInteger(ttl) || ttl < 1) {
|
|
40
57
|
return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease <paths> --ttl 900`);
|
|
41
58
|
}
|
|
59
|
+
const harness = typeof flags.harness === 'string' && flags.harness !== '' ? flags.harness : null;
|
|
42
60
|
|
|
43
61
|
const wanted = [];
|
|
44
62
|
const seen = new Set();
|
|
63
|
+
// The claim is stored in the one spelling the hook's comparison will produce:
|
|
64
|
+
// a claim taken as `src/Keep.mjs` and an edit that spells `src/keep.mjs` must
|
|
65
|
+
// be the same claim on a case-insensitive volume, so the stored path is
|
|
66
|
+
// canonical at write time, not at read time.
|
|
67
|
+
const ignoreCase = coreIgnoreCase(root);
|
|
45
68
|
for (const arg of args) {
|
|
46
|
-
const rel =
|
|
69
|
+
const rel = canonicalLeasePath(root, arg, { ignoreCase });
|
|
47
70
|
if (rel === null) {
|
|
48
71
|
return fail(`${arg} is not a path inside the repository`, EXIT.USAGE, `leases are repository-relative: run: ${CLI} lease <path> --session ${session}`);
|
|
49
72
|
}
|
|
@@ -53,37 +76,19 @@ export async function leaseCommand(args, flags) {
|
|
|
53
76
|
}
|
|
54
77
|
}
|
|
55
78
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (conflicts.length > 0) {
|
|
63
|
-
return fail(
|
|
64
|
-
`${conflicts.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; ')}`,
|
|
65
|
-
EXIT.FAIL,
|
|
66
|
-
`wait for the TTL or ask the holder to release it; run: ${CLI} lease to see every claim`,
|
|
67
|
-
);
|
|
68
|
-
}
|
|
79
|
+
// Older claim files hold the pre-canonical spelling, so a stored path is
|
|
80
|
+
// canonicalised before it is compared: a claim written before the resolver
|
|
81
|
+
// existed must still conflict with a claim taken for the same file.
|
|
82
|
+
const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
|
|
83
|
+
const newClaim = (expiresAt) =>
|
|
84
|
+
wanted.map((path) => ({ path, session, expiresAt, ...(harness === null ? {} : { harness }) }));
|
|
69
85
|
|
|
70
|
-
const expiresAt = Date.now() + ttl * 1000;
|
|
71
|
-
const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
|
|
72
86
|
const mode = writeMode(flags);
|
|
73
|
-
const mine = wanted.map((path) => ({ path, session, expiresAt }));
|
|
74
|
-
|
|
75
87
|
if (!mode.apply) {
|
|
88
|
+
const mine = newClaim(Date.now() + ttl * 1000);
|
|
89
|
+
const apply = `run: ${CLI} lease ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`;
|
|
76
90
|
if (isJson(flags)) {
|
|
77
|
-
emitJson({
|
|
78
|
-
ok: true,
|
|
79
|
-
command: 'lease',
|
|
80
|
-
root,
|
|
81
|
-
dryRun: true,
|
|
82
|
-
session,
|
|
83
|
-
ttl,
|
|
84
|
-
leases: mine,
|
|
85
|
-
remedy: apply,
|
|
86
|
-
});
|
|
91
|
+
emitJson({ ok: true, command: 'lease', root, dryRun: true, session, ttl, leases: mine, remedy: apply });
|
|
87
92
|
return EXIT.OK;
|
|
88
93
|
}
|
|
89
94
|
const lines = [
|
|
@@ -93,14 +98,37 @@ export async function leaseCommand(args, flags) {
|
|
|
93
98
|
...mine.map((l) => `would lease ${l.path} until ${ISO(l.expiresAt)}`),
|
|
94
99
|
`apply ${apply}`,
|
|
95
100
|
];
|
|
96
|
-
emit(lines.join('\n')
|
|
101
|
+
emit(lines.join('\n'));
|
|
97
102
|
return EXIT.OK;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
const
|
|
105
|
+
// The read-modify-write happens under the lock: twelve concurrent claims
|
|
106
|
+
// stored seven before this, because every claimant read the same file and
|
|
107
|
+
// each wrote back only what it had seen (review 0003 Q20).
|
|
108
|
+
const claimed = await withLeaseLock(root, async () => {
|
|
109
|
+
const read = await readLeases(root);
|
|
110
|
+
if (read.error !== null) return { error: read.error, path: read.path };
|
|
111
|
+
const held = activeLeases(read);
|
|
112
|
+
const conflicts = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
|
|
113
|
+
if (conflicts.length > 0) return { conflicts };
|
|
114
|
+
// Keep every other live claim, refresh ours, and let an expired one fall out:
|
|
115
|
+
// the write is also the only sweep the file ever gets.
|
|
116
|
+
const others = held.filter((l) => !seen.has(keyOf(l)));
|
|
117
|
+
const mine = newClaim(Date.now() + ttl * 1000);
|
|
118
|
+
const path = await writeLeases(root, [...others, ...mine]);
|
|
119
|
+
return { mine, path };
|
|
120
|
+
});
|
|
121
|
+
if (claimed.error) {
|
|
122
|
+
return fail(`${claimed.error}`, EXIT.FAIL, `fix or remove ${claimed.path}; the pre-edit hook fails closed on it too`);
|
|
123
|
+
}
|
|
124
|
+
if (claimed.conflicts) {
|
|
125
|
+
return fail(
|
|
126
|
+
`${claimed.conflicts.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; ')}`,
|
|
127
|
+
EXIT.FAIL,
|
|
128
|
+
`wait for the TTL or ask the holder to release it (${CLI} lease release <paths> --session ${session}); run: ${CLI} lease to see every claim`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const { mine, path } = claimed;
|
|
104
132
|
|
|
105
133
|
if (isJson(flags)) {
|
|
106
134
|
emitJson({ ok: true, command: 'lease', root, session, ttl, dryRun: false, leases: mine, file: path });
|
|
@@ -112,7 +140,146 @@ export async function leaseCommand(args, flags) {
|
|
|
112
140
|
`ttl ${ttl}s`,
|
|
113
141
|
...mine.map((l) => `LEASED ${l.path} until ${ISO(l.expiresAt)}`),
|
|
114
142
|
];
|
|
115
|
-
emit(lines.join('\n')
|
|
143
|
+
emit(lines.join('\n'));
|
|
144
|
+
return EXIT.OK;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve release/renew targets to canonical paths, or the usage failure. */
|
|
148
|
+
function resolveTargets(root, args, session, verb) {
|
|
149
|
+
if (args.length === 0) {
|
|
150
|
+
return { error: fail(`lease ${verb} needs paths`, EXIT.USAGE, `run: ${CLI} lease ${verb} <paths> --session <id>`) };
|
|
151
|
+
}
|
|
152
|
+
const ignoreCase = coreIgnoreCase(root);
|
|
153
|
+
const wanted = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const arg of args) {
|
|
156
|
+
const rel = canonicalLeasePath(root, arg, { ignoreCase });
|
|
157
|
+
if (rel === null) {
|
|
158
|
+
return { error: fail(`${arg} is not a path inside the repository`, EXIT.USAGE, `leases are repository-relative`) };
|
|
159
|
+
}
|
|
160
|
+
if (!seen.has(rel)) {
|
|
161
|
+
seen.add(rel);
|
|
162
|
+
wanted.push(rel);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { wanted, seen, ignoreCase };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function claimSession(flags) {
|
|
169
|
+
const session = String(flags.session ?? process.env[SESSION_ENV] ?? '');
|
|
170
|
+
if (session === '') {
|
|
171
|
+
return { error: fail(`lease needs a session to hold the claim`, EXIT.USAGE, `run: ${CLI} lease <paths> --session <id> (or set ${SESSION_ENV})`) };
|
|
172
|
+
}
|
|
173
|
+
return { session };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function releaseLeases(root, args, flags) {
|
|
177
|
+
const { session, error: sessionError } = claimSession(flags);
|
|
178
|
+
if (sessionError !== undefined) return sessionError;
|
|
179
|
+
const resolved = resolveTargets(root, args, session, 'release');
|
|
180
|
+
if (resolved.error !== undefined) return resolved.error;
|
|
181
|
+
const { wanted, seen, ignoreCase } = resolved;
|
|
182
|
+
const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
|
|
183
|
+
const mode = writeMode(flags);
|
|
184
|
+
|
|
185
|
+
const released = await withLeaseLock(root, async () => {
|
|
186
|
+
const read = await readLeases(root);
|
|
187
|
+
if (read.error !== null) return { error: read.error, path: read.path };
|
|
188
|
+
const held = activeLeases(read);
|
|
189
|
+
const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
|
|
190
|
+
if (foreign.length > 0) {
|
|
191
|
+
return {
|
|
192
|
+
conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const dropped = held.filter((l) => seen.has(keyOf(l)));
|
|
196
|
+
if (!mode.apply) return { preview: dropped };
|
|
197
|
+
const kept = held.filter((l) => !seen.has(keyOf(l)));
|
|
198
|
+
const path = await writeLeases(root, kept);
|
|
199
|
+
return { dropped, path };
|
|
200
|
+
});
|
|
201
|
+
if (released.error) {
|
|
202
|
+
return fail(`${released.error}`, EXIT.FAIL, `fix or remove ${released.path}`);
|
|
203
|
+
}
|
|
204
|
+
if (released.conflicts) {
|
|
205
|
+
return fail(`${released.conflicts}`, EXIT.FAIL, `only the holder releases a claim; wait for the TTL or ask ${session} to run: ${CLI} lease release`);
|
|
206
|
+
}
|
|
207
|
+
if (!mode.apply) {
|
|
208
|
+
const lines = [
|
|
209
|
+
`repo ${root}`,
|
|
210
|
+
`session ${session}`,
|
|
211
|
+
...released.preview.map((l) => `would release ${l.path} (held by ${l.session})`),
|
|
212
|
+
...(released.preview.length === 0 ? ['(no active claims on those paths)'] : []),
|
|
213
|
+
`apply run: ${CLI} lease release ${wanted.map((p) => JSON.stringify(p)).join(' ')} --session ${session} --yes`,
|
|
214
|
+
];
|
|
215
|
+
if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: true, session, leases: released.preview });
|
|
216
|
+
else emit(lines.join('\n'));
|
|
217
|
+
return EXIT.OK;
|
|
218
|
+
}
|
|
219
|
+
for (const l of released.dropped) note(`note released ${l.path} (was held by ${l.session})`);
|
|
220
|
+
if (isJson(flags)) emitJson({ ok: true, command: 'lease release', root, dryRun: false, session, leases: released.dropped });
|
|
221
|
+
else {
|
|
222
|
+
emit(
|
|
223
|
+
[
|
|
224
|
+
`repo ${root}`,
|
|
225
|
+
`session ${session}`,
|
|
226
|
+
...released.dropped.map((l) => `RELEASED ${l.path}`),
|
|
227
|
+
...(released.dropped.length === 0 ? ['(no active claims on those paths)'] : []),
|
|
228
|
+
].join('\n'),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
return EXIT.OK;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function renewLeases(root, args, flags) {
|
|
235
|
+
const { session, error: sessionError } = claimSession(flags);
|
|
236
|
+
if (sessionError !== undefined) return sessionError;
|
|
237
|
+
const resolved = resolveTargets(root, args, session, 'renew');
|
|
238
|
+
if (resolved.error !== undefined) return resolved.error;
|
|
239
|
+
const { wanted, seen, ignoreCase } = resolved;
|
|
240
|
+
const ttl = flags.ttl === undefined ? DEFAULT_TTL_SECONDS : Number(flags.ttl);
|
|
241
|
+
if (!Number.isInteger(ttl) || ttl < 1) {
|
|
242
|
+
return fail(`--ttl must be a positive number of seconds, received ${flags.ttl}`, EXIT.USAGE, `example: run: ${CLI} lease renew <paths> --ttl 900`);
|
|
243
|
+
}
|
|
244
|
+
const keyOf = (l) => canonicalLeasePath(root, l.path, { ignoreCase }) ?? l.path;
|
|
245
|
+
const mode = writeMode(flags);
|
|
246
|
+
|
|
247
|
+
const renewed = await withLeaseLock(root, async () => {
|
|
248
|
+
const read = await readLeases(root);
|
|
249
|
+
if (read.error !== null) return { error: read.error, path: read.path };
|
|
250
|
+
const held = activeLeases(read);
|
|
251
|
+
const foreign = held.filter((l) => seen.has(keyOf(l)) && l.session !== session);
|
|
252
|
+
if (foreign.length > 0) {
|
|
253
|
+
return {
|
|
254
|
+
conflicts: foreign.map((c) => `${c.path} is leased by ${c.session} until ${ISO(c.expiresAt)}`).join('; '),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const expiresAt = Date.now() + ttl * 1000;
|
|
258
|
+
const mine = held.map((l) => (seen.has(keyOf(l)) ? { ...l, expiresAt } : l));
|
|
259
|
+
const touched = held.filter((l) => seen.has(keyOf(l)));
|
|
260
|
+
if (!mode.apply) return { preview: touched.map((l) => ({ ...l, expiresAt })) };
|
|
261
|
+
const path = await writeLeases(root, mine);
|
|
262
|
+
return { touched: touched.map((l) => ({ ...l, expiresAt })), path };
|
|
263
|
+
});
|
|
264
|
+
if (renewed.error) {
|
|
265
|
+
return fail(`${renewed.error}`, EXIT.FAIL, `fix or remove ${renewed.path}`);
|
|
266
|
+
}
|
|
267
|
+
if (renewed.conflicts) {
|
|
268
|
+
return fail(`${renewed.conflicts}`, EXIT.FAIL, `only the holder renews a claim; run: ${CLI} lease to see every claim`);
|
|
269
|
+
}
|
|
270
|
+
const rows = mode.apply ? renewed.touched : renewed.preview;
|
|
271
|
+
if (isJson(flags)) emitJson({ ok: true, command: 'lease renew', root, dryRun: !mode.apply, session, ttl, leases: rows });
|
|
272
|
+
else {
|
|
273
|
+
emit(
|
|
274
|
+
[
|
|
275
|
+
`repo ${root}`,
|
|
276
|
+
`session ${session}`,
|
|
277
|
+
`ttl ${ttl}s`,
|
|
278
|
+
...rows.map((l) => `${mode.apply ? 'RENEWED ' : 'would renew '} ${l.path} until ${ISO(l.expiresAt)}`),
|
|
279
|
+
...(rows.length === 0 ? ['(no active claims on those paths)'] : []),
|
|
280
|
+
].join('\n'),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
116
283
|
return EXIT.OK;
|
|
117
284
|
}
|
|
118
285
|
|
|
@@ -128,9 +295,9 @@ async function listLeases(root, flags) {
|
|
|
128
295
|
}
|
|
129
296
|
const lines = [
|
|
130
297
|
`repo ${root}`,
|
|
131
|
-
...held.map((l) => `LEASED ${l.path} ${l.session} until ${ISO(l.expiresAt)}`),
|
|
298
|
+
...held.map((l) => `LEASED ${l.path} ${l.session}${l.harness ? `/${l.harness}` : ''} until ${ISO(l.expiresAt)}`),
|
|
132
299
|
];
|
|
133
300
|
if (held.length === 0) lines.push(`(no active leases)`);
|
|
134
|
-
emit(lines.join('\n')
|
|
301
|
+
emit(lines.join('\n'));
|
|
135
302
|
return EXIT.OK;
|
|
136
303
|
}
|
package/src/commands/lessons.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { resolve } from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
7
7
|
import { diffField, emitDiffs } from '../diff.mjs';
|
|
8
|
+
import { commitJournal } from '../journal.mjs';
|
|
8
9
|
import { CLI } from '../name.mjs';
|
|
9
10
|
import { parsePathList } from '../argv.mjs';
|
|
10
11
|
import {
|
|
@@ -16,19 +17,22 @@ import {
|
|
|
16
17
|
promoteLesson,
|
|
17
18
|
readLessonCandidates,
|
|
18
19
|
} from '../memory/lessons.mjs';
|
|
20
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
19
21
|
|
|
20
22
|
export async function lessonsListCommand(args, flags) {
|
|
21
23
|
if (args.length > 0) {
|
|
22
24
|
return fail(`unknown lessons argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons`);
|
|
23
25
|
}
|
|
24
|
-
const root = resolve(flags
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
const root = resolve(commandRoot(flags));
|
|
27
|
+
// Pure: listing must not capture. Capture used to run here, so `list` wrote
|
|
28
|
+
// to the store it was reading and a read-only question had write side
|
|
29
|
+
// effects. Capturing is `lessons capture` now (review 0003 Q36).
|
|
28
30
|
const [{ candidates, errors }, promoted] = await Promise.all([
|
|
29
31
|
readLessonCandidates(root),
|
|
30
|
-
listPromotedLessons(root),
|
|
32
|
+
listPromotedLessons(root, { includeExpired: true }),
|
|
31
33
|
]);
|
|
34
|
+
const live = promoted.filter((l) => !l.expired);
|
|
35
|
+
const expiredCount = promoted.length - live.length;
|
|
32
36
|
|
|
33
37
|
const payload = {
|
|
34
38
|
ok: errors.length === 0,
|
|
@@ -37,20 +41,21 @@ export async function lessonsListCommand(args, flags) {
|
|
|
37
41
|
ledgerFile: LESSONS_FILE,
|
|
38
42
|
lessonsDir: LESSONS_DIR,
|
|
39
43
|
candidateCount: candidates.length,
|
|
40
|
-
promotedCount:
|
|
44
|
+
promotedCount: live.length,
|
|
45
|
+
expiredCount,
|
|
41
46
|
candidates,
|
|
42
|
-
promoted,
|
|
47
|
+
promoted: live,
|
|
43
48
|
errors,
|
|
44
49
|
};
|
|
45
50
|
|
|
46
51
|
if (isJson(flags)) {
|
|
47
|
-
emitJson(payload
|
|
52
|
+
emitJson(payload);
|
|
48
53
|
return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
const lines = [
|
|
52
57
|
`candidates ${candidates.length} (${LESSONS_FILE})`,
|
|
53
|
-
`promoted ${
|
|
58
|
+
`promoted ${live.length} (${LESSONS_DIR})${expiredCount > 0 ? `, ${expiredCount} expired` : ''}`,
|
|
54
59
|
];
|
|
55
60
|
|
|
56
61
|
if (candidates.length > 0) {
|
|
@@ -62,35 +67,87 @@ export async function lessonsListCommand(args, flags) {
|
|
|
62
67
|
}
|
|
63
68
|
}
|
|
64
69
|
|
|
65
|
-
if (
|
|
70
|
+
if (live.length > 0) {
|
|
66
71
|
lines.push('', 'LESSONS');
|
|
67
|
-
for (const l of
|
|
72
|
+
for (const l of live) {
|
|
68
73
|
lines.push(` ${l.id.padEnd(28)} ${(l.signature ?? 'no-signature').slice(0, 19)} ${l.title}`);
|
|
69
74
|
}
|
|
70
75
|
}
|
|
76
|
+
if (expiredCount > 0) {
|
|
77
|
+
lines.push('', `EXPIRED ${expiredCount}`);
|
|
78
|
+
for (const l of promoted.filter((l) => l.expired)) {
|
|
79
|
+
lines.push(` ${l.id.padEnd(28)} expired ${l.expires ?? 'no date'} ${l.title}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
71
82
|
|
|
72
83
|
for (const err of errors) lines.push(`error ${err}`);
|
|
73
84
|
|
|
74
|
-
emit(lines.join('\n')
|
|
85
|
+
emit(lines.join('\n'));
|
|
75
86
|
return errors.length === 0 ? EXIT.OK : EXIT.FAIL;
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
/**
|
|
90
|
+
* `simt lessons capture` — the explicit write half of the old `list`.
|
|
91
|
+
*
|
|
92
|
+
* Scans the ledger for fail-then-fix sequences and git history for reverts,
|
|
93
|
+
* and appends any new candidates. A command that writes says so in its name;
|
|
94
|
+
* `list` and `review` previews do not (review 0003 Q36).
|
|
95
|
+
*/
|
|
96
|
+
export async function lessonsCaptureCommand(args, flags) {
|
|
97
|
+
if (args.length > 0) {
|
|
98
|
+
return fail(`unknown lessons capture argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} lessons capture`);
|
|
99
|
+
}
|
|
100
|
+
const root = resolve(commandRoot(flags));
|
|
101
|
+
const fromLedger = await captureFromLedger(root);
|
|
102
|
+
const reverts = await captureRevertedCommits(root);
|
|
103
|
+
|
|
104
|
+
const payload = {
|
|
105
|
+
ok: true,
|
|
106
|
+
command: 'lessons capture',
|
|
107
|
+
root,
|
|
108
|
+
failThenFix: fromLedger.length,
|
|
109
|
+
reverts: reverts.length,
|
|
110
|
+
captured: [...fromLedger, ...reverts],
|
|
111
|
+
};
|
|
112
|
+
if (isJson(flags)) {
|
|
113
|
+
emitJson(payload);
|
|
114
|
+
return EXIT.OK;
|
|
115
|
+
}
|
|
116
|
+
const lines = [
|
|
117
|
+
`captured ${payload.captured.length} candidates (${LESSONS_FILE})`,
|
|
118
|
+
` fail-then-fix ${fromLedger.length}`,
|
|
119
|
+
` reverts ${reverts.length}`,
|
|
120
|
+
];
|
|
121
|
+
for (const c of payload.captured) {
|
|
122
|
+
lines.push(` ${c.id} ${c.kind} ${c.summary}`);
|
|
123
|
+
}
|
|
124
|
+
emit(lines.join('\n'));
|
|
125
|
+
return EXIT.OK;
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
export async function lessonsReviewCommand(args, flags) {
|
|
79
129
|
if (args.length > 1) {
|
|
80
130
|
return fail(`unknown lessons review argument ${args[1]}`, EXIT.USAGE, `run: ${CLI} lessons review [<id>] [--yes]`);
|
|
81
131
|
}
|
|
82
|
-
const root = resolve(flags
|
|
132
|
+
const root = resolve(commandRoot(flags));
|
|
83
133
|
const mode = writeMode(flags);
|
|
84
134
|
const parsedPaths = parsePathList(flags.paths);
|
|
85
135
|
const paths = parsedPaths.length > 0 ? parsedPaths : undefined;
|
|
86
136
|
const title = flags.task ? String(flags.task) : undefined;
|
|
137
|
+
const expires = typeof flags.expires === 'string' && flags.expires.trim() !== '' ? flags.expires.trim() : undefined;
|
|
87
138
|
|
|
139
|
+
// Journaled so `undo` restores the promoted file (R49); committed only when
|
|
140
|
+
// the run actually wrote, which commitJournal already guarantees.
|
|
141
|
+
const journal = [];
|
|
88
142
|
const result = await promoteLesson(root, {
|
|
89
143
|
id: args[0],
|
|
90
144
|
apply: mode.apply,
|
|
91
145
|
paths,
|
|
92
146
|
title,
|
|
147
|
+
expires,
|
|
148
|
+
journal: mode.apply ? journal : undefined,
|
|
93
149
|
});
|
|
150
|
+
if (mode.apply && result.ok) await commitJournal(root, 'lessons review', journal);
|
|
94
151
|
|
|
95
152
|
if (!result.ok) {
|
|
96
153
|
return fail(result.error, EXIT.FAIL, `record a session failure-then-fix or run: ${CLI} lessons`);
|
|
@@ -117,7 +174,7 @@ export async function lessonsReviewCommand(args, flags) {
|
|
|
117
174
|
};
|
|
118
175
|
|
|
119
176
|
if (isJson(flags)) {
|
|
120
|
-
emitJson(payload
|
|
177
|
+
emitJson(payload);
|
|
121
178
|
return EXIT.OK;
|
|
122
179
|
}
|
|
123
180
|
|
|
@@ -129,7 +186,7 @@ export async function lessonsReviewCommand(args, flags) {
|
|
|
129
186
|
result.markdown.trimEnd(),
|
|
130
187
|
];
|
|
131
188
|
|
|
132
|
-
emit(lines.join('\n')
|
|
189
|
+
emit(lines.join('\n'));
|
|
133
190
|
// After the report: the report says what, the diff shows it (R50).
|
|
134
191
|
emitDiffs(diffs, flags);
|
|
135
192
|
if (!mode.apply) {
|
package/src/commands/local.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { resolve } from 'node:path';
|
|
|
8
8
|
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
9
9
|
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
10
10
|
import { probeLocal } from '../local/client.mjs';
|
|
11
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
11
12
|
|
|
12
13
|
function formatRoleLine(role, report) {
|
|
13
14
|
if (!report) return `${role.padEnd(8)} (unset)`;
|
|
@@ -55,7 +56,7 @@ function remedyFor(result) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
export async function localDoctorCommand(args, flags) {
|
|
58
|
-
const root = resolve(flags
|
|
59
|
+
const root = resolve(commandRoot(flags, args));
|
|
59
60
|
const allowRemote = flags.allowRemote === true;
|
|
60
61
|
const result = await probeLocal(root, { allowRemote });
|
|
61
62
|
|
|
@@ -86,7 +87,6 @@ export async function localDoctorCommand(args, flags) {
|
|
|
86
87
|
findings: result.findings,
|
|
87
88
|
...(error ? { error, remedy, exitCode: code } : {}),
|
|
88
89
|
},
|
|
89
|
-
{ maxBytes: flags.maxBytes ?? 0 },
|
|
90
90
|
);
|
|
91
91
|
return code;
|
|
92
92
|
}
|
|
@@ -99,7 +99,7 @@ export async function localDoctorCommand(args, flags) {
|
|
|
99
99
|
`sockets ${result.socketsOpened}`,
|
|
100
100
|
];
|
|
101
101
|
|
|
102
|
-
emit(lines.join('\n')
|
|
102
|
+
emit(lines.join('\n'));
|
|
103
103
|
|
|
104
104
|
if (!result.ok) {
|
|
105
105
|
return fail(error, code, remedy);
|
package/src/commands/map.mjs
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
// `simt map` ranks the files a task is most likely to touch (R22, ADR-091).
|
|
2
|
-
// The cache is
|
|
3
|
-
//
|
|
4
|
-
// the
|
|
2
|
+
// The cache is refreshed from the git index on every read: `reindex` skips unchanged
|
|
3
|
+
// blobs, so a current repository pays for one `git ls-files -s`, not a walk of the
|
|
4
|
+
// tree. A map of stale files is a wrong answer, and the commands that read with
|
|
5
|
+
// `build` on are ones a person typed at a terminal, where a few hundred
|
|
6
|
+
// milliseconds is not a bar to meet.
|
|
5
7
|
|
|
6
|
-
import { EXIT, emit, emitJson, fail, isJson } from '../output/index.mjs';
|
|
8
|
+
import { EXIT, emit, emitJson, fail, isJson, present } from '../output/index.mjs';
|
|
7
9
|
import { CLI } from '../name.mjs';
|
|
8
|
-
import { readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
|
|
10
|
+
import { isIndexStale, readEdges, readEmbeddings, readFiles, readFts, readSymbols, reindex, searchFtsBm25 } from '../index/store.mjs';
|
|
9
11
|
import {
|
|
10
12
|
DEFAULT_BUDGET,
|
|
11
13
|
embedTaskQuery,
|
|
@@ -14,12 +16,13 @@ import {
|
|
|
14
16
|
rankFiles,
|
|
15
17
|
renderMap,
|
|
16
18
|
} from '../index/map.mjs';
|
|
19
|
+
import { commandRoot } from '../repo-root.mjs';
|
|
17
20
|
|
|
18
21
|
const LEVELS = new Set(['outline', 'names', 'signatures']);
|
|
19
22
|
|
|
20
23
|
export async function mapCommand(args, flags) {
|
|
21
24
|
if (args.length > 0) return fail(`unknown map argument ${args[0]}`, EXIT.USAGE, `run: ${CLI} map`);
|
|
22
|
-
const root = flags
|
|
25
|
+
const root = commandRoot(flags);
|
|
23
26
|
if (flags.recall === true) return recall(root, flags);
|
|
24
27
|
const level = flags.level ?? 'outline';
|
|
25
28
|
if (!LEVELS.has(level)) {
|
|
@@ -42,12 +45,19 @@ export async function mapCommand(args, flags) {
|
|
|
42
45
|
: null;
|
|
43
46
|
const ranked = rankFiles({ ...loaded, bm25, queryVector, task });
|
|
44
47
|
const rendered = renderMap(ranked, loaded.symbols, { level, budget });
|
|
48
|
+
// What the ranking is an answer to. `graph-only` says no file or symbol mentioned the
|
|
49
|
+
// task and the list is the graph hub; reading that as a hit is how `map --task
|
|
50
|
+
// zebracorn` looked useful (review 0003 Q52).
|
|
51
|
+
const matched = ranked.filter((row) => (row.score ?? 0) > 0).length;
|
|
52
|
+
const basis = task.trim().length === 0 ? 'no-task' : matched > 0 ? 'matched' : 'graph-only';
|
|
45
53
|
const payload = {
|
|
46
54
|
ok: true,
|
|
47
55
|
command: 'map',
|
|
48
56
|
root,
|
|
49
57
|
level,
|
|
50
58
|
budget,
|
|
59
|
+
matched,
|
|
60
|
+
basis,
|
|
51
61
|
shown: rendered.shown,
|
|
52
62
|
total: rendered.total,
|
|
53
63
|
truncated: rendered.truncated,
|
|
@@ -55,17 +65,18 @@ export async function mapCommand(args, flags) {
|
|
|
55
65
|
text: rendered.text,
|
|
56
66
|
};
|
|
57
67
|
if (isJson(flags)) {
|
|
58
|
-
emitJson(payload
|
|
68
|
+
emitJson(payload);
|
|
59
69
|
return EXIT.OK;
|
|
60
70
|
}
|
|
61
71
|
const lines = [
|
|
62
72
|
`level ${level}`,
|
|
73
|
+
`basis ${basis}${basis === 'graph-only' ? ' (no file or symbol mentions this task; the list is the graph hub)' : ''}`,
|
|
63
74
|
`shown ${rendered.shown}`,
|
|
64
75
|
`total ${rendered.total}`,
|
|
65
76
|
`budget ${budget}`,
|
|
66
77
|
];
|
|
67
78
|
if (rendered.text) lines.push(rendered.text);
|
|
68
|
-
emit(lines.join('\n')
|
|
79
|
+
emit(lines.join('\n'));
|
|
69
80
|
return EXIT.OK;
|
|
70
81
|
}
|
|
71
82
|
|
|
@@ -80,29 +91,27 @@ async function recall(root, flags) {
|
|
|
80
91
|
return fail(`could not measure recall (${error.message})`, EXIT.UNSUPPORTED, `run ${CLI} index in a git repository`);
|
|
81
92
|
}
|
|
82
93
|
const payload = { ok: true, command: 'map', root, recall: result };
|
|
83
|
-
|
|
84
|
-
emitJson(payload, { pretty: flags.pretty === true, maxBytes: flags.maxBytes ?? 0 });
|
|
85
|
-
return EXIT.OK;
|
|
86
|
-
}
|
|
87
|
-
emit(formatRecall(result), { maxBytes: flags.maxBytes ?? 0 });
|
|
94
|
+
present(payload, () => formatRecall(result), flags);
|
|
88
95
|
return EXIT.OK;
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
/**
|
|
92
|
-
* The files, symbols, edges, FTS rows and stored vector embeddings the index holds
|
|
93
|
-
* building the index once when it is empty.
|
|
99
|
+
* The files, symbols, edges, FTS rows and stored vector embeddings the index holds.
|
|
94
100
|
*
|
|
95
|
-
* `build
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
+
* With `build` (the default) the index is refreshed from the git index before it is
|
|
102
|
+
* read. The refresh is incremental — a blob that has not changed is not re-read — so
|
|
103
|
+
* the cost of a current repository is one `git ls-files -s`, not a walk of the tree.
|
|
104
|
+
* The callers that build are commands a person typed: `map`, `brief`, `slice`.
|
|
105
|
+
*
|
|
106
|
+
* `build: false` refuses the refresh and answers with `cold: true` when the index is
|
|
107
|
+
* empty. The callers that pass it are the prompt-submit hook (R29) and the MCP
|
|
108
|
+
* surface: a delivered packet that walked the whole repository would make a person's
|
|
109
|
+
* first prompt in a fresh clone wait, and a prompt that takes a minute to appear is
|
|
110
|
+
* how a hook gets uninstalled. The next prompt gets the packet, and a command a
|
|
111
|
+
* person typed at a terminal always builds what it needs.
|
|
101
112
|
*/
|
|
102
113
|
export async function loadGraph(root, { build = true } = {}) {
|
|
103
|
-
|
|
104
|
-
if (files.length === 0) {
|
|
105
|
-
if (!build) return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true };
|
|
114
|
+
if (build) {
|
|
106
115
|
try {
|
|
107
116
|
await reindex(root);
|
|
108
117
|
} catch (error) {
|
|
@@ -114,13 +123,24 @@ export async function loadGraph(root, { build = true } = {}) {
|
|
|
114
123
|
),
|
|
115
124
|
};
|
|
116
125
|
}
|
|
117
|
-
files = await readFiles(root);
|
|
118
126
|
}
|
|
119
|
-
const [symbols, edges, fts, embeddings] = await Promise.all([
|
|
127
|
+
const [files, symbols, edges, fts, embeddings] = await Promise.all([
|
|
128
|
+
readFiles(root),
|
|
120
129
|
readSymbols(root),
|
|
121
130
|
readEdges(root),
|
|
122
131
|
readFts(root),
|
|
123
132
|
readEmbeddings(root),
|
|
124
133
|
]);
|
|
125
|
-
|
|
134
|
+
// A present index that cannot be read is named, not served as empty
|
|
135
|
+
// (review 0003 Q44): `cold` is "nothing here", `broken` is "something is
|
|
136
|
+
// here and the read failed".
|
|
137
|
+
const broken = !build && Boolean(files.error);
|
|
138
|
+
if (!build && files.length === 0) {
|
|
139
|
+
return { files: [], symbols: [], edges: [], fts: [], embeddings: [], cold: true, broken, stale: false };
|
|
140
|
+
}
|
|
141
|
+
// A caller that may not build still learns the index is old (review 0003
|
|
142
|
+
// Q42): the digest compare costs one `git ls-files -s`, and the freshness
|
|
143
|
+
// signal is what lets a UI distrust the packet instead of quoting it.
|
|
144
|
+
const stale = !build && files.length > 0 && (await isIndexStale(root));
|
|
145
|
+
return { files, symbols, edges, fts, embeddings, stale, broken };
|
|
126
146
|
}
|