simt-dev 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +2 -2
- package/agents/simt-agent.md +84 -0
- package/docs/decisions/ADR-009-skill-layout.md +1 -1
- package/docs/decisions/ADR-086-the-stable-prefix-comes-first-and-the-lock-records-both-digests.md +1 -1
- package/docs/decisions/ADR-106-the-harness-hook-runs-through-a-repo-shim.md +46 -0
- package/docs/decisions/ADR-107-an-install-can-name-one-harness-and-the-skipped-ones-are-named.md +51 -0
- package/docs/decisions/ADR-108-an-adoption-builds-the-index-and-a-command-refreshes-it-on-read.md +59 -0
- package/docs/decisions/ADR-109-simt-dev-is-its-own-consumer-and-the-guards-that-kept-it-from-being-one-are-retired.md +47 -0
- package/docs/decisions/ADR-110-the-lock-file-follows-the-manifest-name-and-the-old-name-is-read-until-the-next-write.md +43 -0
- package/docs/decisions/ADR-111-the-tool-ships-one-agent-rendered-per-harness-and-installs-it-with-init-and-a-command.md +75 -0
- package/docs/decisions/ADR-112-the-registry-is-what-simt-dev-ships-and-a-repo-owns-its-own-skills.md +81 -0
- package/docs/decisions/ADR-113-a-pre-push-that-carries-only-documentation-skips-the-test-gate-and-says-so.md +87 -0
- package/docs/decisions/ADR-114-the-bench-pairs-at-the-task-level-a-missing-cost-is-not-zero-and-an-unasked-cross-group-compare-is-refused.md +74 -0
- package/docs/decisions/ADR-115-the-index-stamps-its-schema-version-migrates-older-databases-in-place-and-serves-newer-ones-from-a-sidecar.md +69 -0
- package/docs/decisions/ADR-116-rust-support-reaches-the-example-path-the-indent-and-the-bench-runner.md +100 -0
- package/docs/decisions/README.md +9 -0
- package/package.json +4 -2
- package/rules/core.md +2 -2
- 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 +6 -2
- package/src/agenthooks/render.mjs +186 -19
- package/src/agenthooks/shim.mjs +55 -0
- package/src/agents/render.mjs +277 -0
- package/src/argv.mjs +8 -0
- package/src/bench/ablate.mjs +31 -14
- package/src/bench/mine.mjs +48 -1
- package/src/bench/probes.mjs +5 -6
- package/src/bench/report.mjs +243 -80
- package/src/bench/run.mjs +12 -11
- package/src/bench/variants.mjs +112 -40
- package/src/brief/assemble.mjs +6 -0
- package/src/ci/workflow.mjs +16 -7
- package/src/commands/agent.mjs +206 -0
- package/src/commands/agenthook.mjs +109 -15
- package/src/commands/bench.mjs +2 -1
- package/src/commands/compare.mjs +8 -7
- package/src/commands/decide.mjs +6 -5
- package/src/commands/evidence.mjs +13 -3
- package/src/commands/gate.mjs +85 -19
- package/src/commands/graph.mjs +18 -5
- package/src/commands/help.mjs +35 -14
- package/src/commands/hooks.mjs +68 -25
- package/src/commands/impact.mjs +4 -2
- package/src/commands/index.mjs +17 -4
- package/src/commands/init.mjs +169 -37
- package/src/commands/lease.mjs +203 -35
- package/src/commands/lessons.mjs +65 -9
- package/src/commands/map.mjs +33 -18
- package/src/commands/plugin.mjs +1 -1
- package/src/commands/repoindex.mjs +1 -0
- package/src/commands/rules.mjs +92 -18
- package/src/commands/skills.mjs +60 -25
- package/src/commands/skillscompile.mjs +8 -7
- package/src/commands/skillsinstall.mjs +12 -0
- package/src/commands/slice.mjs +377 -18
- package/src/commands/sync.mjs +37 -22
- package/src/commands/tests.mjs +5 -2
- package/src/commands/undo.mjs +24 -16
- package/src/commands/upgrade.mjs +33 -1
- package/src/commands/why.mjs +10 -4
- package/src/compare/hooksstate.mjs +23 -1
- package/src/compare/learn.mjs +82 -57
- package/src/compare/parallel.mjs +50 -0
- package/src/compare/row.mjs +26 -1
- package/src/completions.mjs +5 -0
- package/src/decisions/dir.mjs +84 -0
- package/src/decisions/index.mjs +109 -11
- package/src/distill/distill.mjs +3 -1
- package/src/ecosystem/node-runner.mjs +144 -0
- package/src/explain.mjs +18 -6
- package/src/graph/load.mjs +38 -19
- package/src/hooks/capability.mjs +14 -1
- package/src/hooks/dispatcher.mjs +335 -13
- package/src/hooks/resolve.mjs +9 -3
- package/src/hooks/shim.mjs +12 -7
- package/src/identity.mjs +67 -14
- 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 +296 -77
- 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 +91 -25
- package/src/mcp/resources.mjs +5 -3
- package/src/mcp/server.mjs +81 -22
- package/src/mcp/tools.mjs +45 -9
- package/src/memory/decide.mjs +98 -43
- package/src/memory/lessons.mjs +168 -33
- package/src/name.mjs +9 -0
- 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 +21 -14
- package/src/projection/manifest.mjs +69 -19
- package/src/projection/markers.mjs +43 -3
- package/src/projection/mdc.mjs +34 -7
- package/src/projection/project.mjs +162 -18
- package/src/projection/rules.mjs +54 -3
- package/src/projection/surfaces.mjs +9 -1
- package/src/repo-root.mjs +69 -0
- package/src/resolve/body.mjs +4 -0
- package/src/resolve/fingerprint.mjs +5 -0
- package/src/resolve/local.mjs +58 -0
- package/src/review/critic.mjs +4 -2
- package/src/secrets.mjs +165 -22
- package/src/state.mjs +76 -0
- package/src/status.mjs +9 -4
- package/src/toolwords.mjs +5 -0
- package/src/tsconfig.mjs +39 -99
- package/templates/plan/tracker.json +1 -1
package/src/commands/slice.mjs
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A row in a tracker is a promise, and these are the two moments it is acted on.
|
|
4
4
|
// `start` isolates the work: a linked worktree on a branch named for the row, so the
|
|
5
|
-
// main checkout stays free for whatever else is running, and
|
|
6
|
-
//
|
|
7
|
-
// agent picks it up. `finish` closes the loop the way the repository's own hook would:
|
|
5
|
+
// main checkout stays free for whatever else is running, and a task brief assembled in
|
|
6
|
+
// the main checkout at `.simt/local/briefs/<id>.md`, beside the worktree and outside
|
|
7
|
+
// its tree, where an agent picks it up. `finish` closes the loop the way the repository's own hook would:
|
|
8
8
|
// the declared gates run through the shared dispatcher first, because a command that
|
|
9
9
|
// commits for you may not assume a hook is installed to stop it, then the commit
|
|
10
10
|
// carries a Conventional subject built from the row's own title, and only then does
|
|
@@ -33,15 +33,40 @@ import { promisify } from 'node:util';
|
|
|
33
33
|
|
|
34
34
|
import { EXIT, emit, emitJson, fail, isJson, note, writeMode } from '../output/index.mjs';
|
|
35
35
|
import { planDone, planNext } from '../plan/index.mjs';
|
|
36
|
+
import { loadFormat } from '../plan/template.mjs';
|
|
36
37
|
import { assembleBrief } from '../brief/assemble.mjs';
|
|
37
38
|
import { renderGateReport, runGates, stagedFiles } from '../hooks/dispatcher.mjs';
|
|
39
|
+
import { GATES } from '../hooks/gates.mjs';
|
|
40
|
+
import { indexTreeId, passEntryForGate, readLedger } from '../ledger/index.mjs';
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_TTL_SECONDS,
|
|
43
|
+
activeLeases,
|
|
44
|
+
canonicalLeasePath,
|
|
45
|
+
readLeases,
|
|
46
|
+
withLeaseLock,
|
|
47
|
+
writeLeases,
|
|
48
|
+
} from '../lease.mjs';
|
|
49
|
+
import { SESSION_ENV } from '../agenthooks/policy.mjs';
|
|
50
|
+
import { assertVersionPin } from '../package-version.mjs';
|
|
51
|
+
import { coreIgnoreCase } from '../repo-root.mjs';
|
|
38
52
|
import { readManifest } from '../projection/manifest.mjs';
|
|
39
|
-
import { CLI, MANIFEST_FILE
|
|
53
|
+
import { CLI, MANIFEST_FILE } from '../name.mjs';
|
|
54
|
+
import { LOCAL_DIR } from '../state.mjs';
|
|
40
55
|
|
|
41
56
|
const exec = promisify(execFile);
|
|
42
57
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
58
|
+
/**
|
|
59
|
+
* The state a started slice moves to: the template's `started`-role state, not
|
|
60
|
+
* a hard-coded word. A repository that renamed its states still starts slices,
|
|
61
|
+
* and one that deleted the in-progress state fails closed in `plan done` on a
|
|
62
|
+
* word its own template never defined (review 0003 Q23).
|
|
63
|
+
*/
|
|
64
|
+
async function startedState(main) {
|
|
65
|
+
const loaded = await loadFormat(main);
|
|
66
|
+
if (loaded.error) return { error: loaded.error, remedy: loaded.remedy };
|
|
67
|
+
const { format } = loaded;
|
|
68
|
+
return { state: format.startedWrite ?? format.canonical('in-progress') ?? 'In progress' };
|
|
69
|
+
}
|
|
45
70
|
|
|
46
71
|
/**
|
|
47
72
|
* A Conventional Commits type: one lower-case word, optionally scoped, optionally
|
|
@@ -74,12 +99,16 @@ async function mainCheckout(root) {
|
|
|
74
99
|
}
|
|
75
100
|
}
|
|
76
101
|
|
|
77
|
-
/**
|
|
102
|
+
/**
|
|
103
|
+
* The branch and worktree a slice owns. Both derive from the id: one id, one spelling.
|
|
104
|
+
* The worktree lives under machine-local state (review 0003 Q19): a worktree inside
|
|
105
|
+
* the checkout would be recorded as an embedded gitlink and would change the tree id.
|
|
106
|
+
*/
|
|
78
107
|
function places(main, id) {
|
|
79
108
|
const slug = id.toLowerCase();
|
|
80
109
|
return {
|
|
81
110
|
branch: `slice/${slug}`,
|
|
82
|
-
worktree: join(main,
|
|
111
|
+
worktree: join(main, LOCAL_DIR, 'worktrees', slug),
|
|
83
112
|
};
|
|
84
113
|
}
|
|
85
114
|
|
|
@@ -104,6 +133,81 @@ async function resolveSlice(main, id) {
|
|
|
104
133
|
return { slice: found.next };
|
|
105
134
|
}
|
|
106
135
|
|
|
136
|
+
/** The session holding the slice, or null when the caller named none: a person
|
|
137
|
+
* at a terminal has no session, and starting without one proceeds unleased
|
|
138
|
+
* rather than refusing the work. */
|
|
139
|
+
function sliceSession(flags) {
|
|
140
|
+
const session = flags.session ?? process.env[SESSION_ENV] ?? null;
|
|
141
|
+
return typeof session === 'string' && session !== '' ? session : null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The slice's lease path, in the spelling the pre-edit hook compares: the
|
|
146
|
+
* worktree directory, repository-relative. Entries carry `kind: 'slice'` so a
|
|
147
|
+
* later schema (review 0003 Q74) can tell slice claims from path claims; the
|
|
148
|
+
* v1 reader keeps the extra fields without understanding them.
|
|
149
|
+
*/
|
|
150
|
+
function sliceLeaseRel(main, worktree) {
|
|
151
|
+
return canonicalLeasePath(main, worktree, { ignoreCase: coreIgnoreCase(main) });
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Claim the slice for one session: a second session starting the same slice
|
|
156
|
+
* is refused with the holder's name instead of racing it to the worktree.
|
|
157
|
+
* A claim held by this session is refreshed; anything else on the file is
|
|
158
|
+
* left alone.
|
|
159
|
+
*/
|
|
160
|
+
async function claimSlice(main, id, leaseRel, session) {
|
|
161
|
+
try {
|
|
162
|
+
return await withLeaseLock(main, async () => {
|
|
163
|
+
const read = await readLeases(main);
|
|
164
|
+
if (read.error !== null) return { error: read.error, path: read.path };
|
|
165
|
+
const held = activeLeases(read);
|
|
166
|
+
const other = held.find(
|
|
167
|
+
(l) => l.session !== session && ((l.kind === 'slice' && l.slice === id) || (leaseRel !== null && l.path === leaseRel)),
|
|
168
|
+
);
|
|
169
|
+
if (other) return { conflict: other };
|
|
170
|
+
const mine = {
|
|
171
|
+
path: leaseRel,
|
|
172
|
+
session,
|
|
173
|
+
kind: 'slice',
|
|
174
|
+
slice: id,
|
|
175
|
+
expiresAt: Date.now() + DEFAULT_TTL_SECONDS * 1000,
|
|
176
|
+
};
|
|
177
|
+
const kept = held.filter((l) => !(l.kind === 'slice' && l.slice === id && l.session === session));
|
|
178
|
+
const path = await writeLeases(main, [...kept, mine]);
|
|
179
|
+
return { mine, path };
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { error: error.message };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Release this session's claim on the slice. Best-effort by design: `finish`
|
|
188
|
+
* calls it after the commit exists, so a lease failure is a note about a TTL
|
|
189
|
+
* that will clean itself up, never a second failure stacked on a success.
|
|
190
|
+
*/
|
|
191
|
+
async function releaseSlice(main, id, session) {
|
|
192
|
+
if (session === null) return { released: false };
|
|
193
|
+
try {
|
|
194
|
+
return await withLeaseLock(main, async () => {
|
|
195
|
+
const read = await readLeases(main);
|
|
196
|
+
if (read.error !== null) return { error: read.error };
|
|
197
|
+
const held = activeLeases(read);
|
|
198
|
+
const mine = held.filter((l) => l.kind === 'slice' && l.slice === id && l.session === session);
|
|
199
|
+
if (mine.length === 0) return { released: false };
|
|
200
|
+
await writeLeases(
|
|
201
|
+
main,
|
|
202
|
+
held.filter((l) => !mine.includes(l)),
|
|
203
|
+
);
|
|
204
|
+
return { released: true };
|
|
205
|
+
});
|
|
206
|
+
} catch (error) {
|
|
207
|
+
return { error: error.message };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
107
211
|
export async function sliceCommand(sub, args, flags) {
|
|
108
212
|
const root = flags.root ?? process.cwd();
|
|
109
213
|
if (sub === 'start') {
|
|
@@ -116,7 +220,16 @@ export async function sliceCommand(sub, args, flags) {
|
|
|
116
220
|
if (!id) return fail(`${CLI} slice finish needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
|
|
117
221
|
return finishSlice(root, id, flags);
|
|
118
222
|
}
|
|
119
|
-
|
|
223
|
+
if (sub === 'abandon') {
|
|
224
|
+
const id = args[0];
|
|
225
|
+
if (!id) return fail(`${CLI} slice abandon needs a slice id`, EXIT.USAGE, `run: ${CLI} plan next`);
|
|
226
|
+
return abandonSlice(root, id, flags);
|
|
227
|
+
}
|
|
228
|
+
return fail(
|
|
229
|
+
`unknown slice subcommand ${sub}`,
|
|
230
|
+
EXIT.USAGE,
|
|
231
|
+
`run: ${CLI} slice start <id> | ${CLI} slice finish <id> | ${CLI} slice abandon <id>`,
|
|
232
|
+
);
|
|
120
233
|
}
|
|
121
234
|
|
|
122
235
|
async function startSlice(root, id, flags) {
|
|
@@ -126,16 +239,25 @@ async function startSlice(root, id, flags) {
|
|
|
126
239
|
if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
|
|
127
240
|
const { title, doc } = resolved.slice;
|
|
128
241
|
const { branch, worktree } = places(main, id);
|
|
129
|
-
|
|
242
|
+
// The brief lives beside the worktree, not in it: a file inside the worktree
|
|
243
|
+
// is untracked content in the slice's own tree, and the tree id must name
|
|
244
|
+
// the code, not the briefing (review 0003 Q19, Q23).
|
|
245
|
+
const briefPath = join(main, LOCAL_DIR, 'briefs', `${id.toLowerCase()}.md`);
|
|
246
|
+
const leaseRel = sliceLeaseRel(main, worktree);
|
|
247
|
+
const session = sliceSession(flags);
|
|
130
248
|
const task = `${id}: ${String(title ?? '').trim()}`;
|
|
131
249
|
const apply = `run: ${CLI} slice start ${id} --yes`;
|
|
132
250
|
|
|
251
|
+
const started = await startedState(main);
|
|
252
|
+
if (started.error) return fail(started.error, EXIT.USAGE, started.remedy);
|
|
133
253
|
// The row first, dry, against the main checkout: it is the only part of `start`
|
|
134
254
|
// that can refuse for a reason about the tracker rather than about git, and a
|
|
135
255
|
// preview that discovers it late is a preview that half-described the work.
|
|
136
|
-
const preview = await planDone(main, id, { state:
|
|
256
|
+
const preview = await planDone(main, id, { state: started.state, dryRun: true });
|
|
137
257
|
if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
|
|
138
258
|
|
|
259
|
+
const leaseLine = session === null ? 'lease (no session: pass --session or set SIMT_SESSION to lease the slice)' : `lease slice ${id} as ${session} (claimed on apply)`;
|
|
260
|
+
|
|
139
261
|
if (!mode.apply) {
|
|
140
262
|
if (isJson(flags)) {
|
|
141
263
|
emitJson({
|
|
@@ -148,6 +270,7 @@ async function startSlice(root, id, flags) {
|
|
|
148
270
|
branch,
|
|
149
271
|
worktree: relative(main, worktree),
|
|
150
272
|
brief: { path: relative(main, briefPath), bytes: null },
|
|
273
|
+
lease: session === null ? null : { slice: id, path: leaseRel, session },
|
|
151
274
|
state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
|
|
152
275
|
remedy: apply,
|
|
153
276
|
});
|
|
@@ -159,6 +282,7 @@ async function startSlice(root, id, flags) {
|
|
|
159
282
|
`branch ${branch}`,
|
|
160
283
|
`worktree ${relative(main, worktree)}`,
|
|
161
284
|
`brief ${relative(main, briefPath)} (assembled on apply)`,
|
|
285
|
+
leaseLine,
|
|
162
286
|
`state ${preview.from} -> ${preview.to}`,
|
|
163
287
|
`apply ${apply}`,
|
|
164
288
|
];
|
|
@@ -178,6 +302,26 @@ async function startSlice(root, id, flags) {
|
|
|
178
302
|
return fail(`the index at ${main} is empty and will not build here`, EXIT.UNSUPPORTED, `run: ${CLI} index`);
|
|
179
303
|
}
|
|
180
304
|
|
|
305
|
+
// Claimed before the worktree exists: a second session starting the same slice
|
|
306
|
+
// is refused here, not after a worktree that then has to be taken back down.
|
|
307
|
+
let lease = null;
|
|
308
|
+
if (session !== null) {
|
|
309
|
+
const claimed = await claimSlice(main, id, leaseRel, session);
|
|
310
|
+
if (claimed.error) {
|
|
311
|
+
return fail(`the slice could not be leased: ${claimed.error}`, EXIT.FAIL, `run: ${CLI} lease to see every claim`);
|
|
312
|
+
}
|
|
313
|
+
if (claimed.conflict) {
|
|
314
|
+
const holder = claimed.conflict.session;
|
|
315
|
+
const until = new Date(claimed.conflict.expiresAt).toISOString();
|
|
316
|
+
return fail(
|
|
317
|
+
`${id} is already started by session ${holder} (lease until ${until})`,
|
|
318
|
+
EXIT.FAIL,
|
|
319
|
+
`wait for the TTL or ask ${holder} to finish or abandon it; run: ${CLI} lease to see every claim`,
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
lease = claimed.mine;
|
|
323
|
+
}
|
|
324
|
+
|
|
181
325
|
try {
|
|
182
326
|
await git(main, ['worktree', 'add', '-b', branch, worktree]);
|
|
183
327
|
} catch (error) {
|
|
@@ -185,15 +329,16 @@ async function startSlice(root, id, flags) {
|
|
|
185
329
|
return fail(`the worktree could not be created: ${detail}`, EXIT.FAIL, `run: git -C ${main} worktree list`);
|
|
186
330
|
}
|
|
187
331
|
|
|
188
|
-
await mkdir(
|
|
332
|
+
await mkdir(dirname(briefPath), { recursive: true });
|
|
189
333
|
await writeFile(briefPath, packet.packed.text, 'utf8');
|
|
190
334
|
|
|
191
|
-
const row = await planDone(main, id, { state:
|
|
335
|
+
const row = await planDone(main, id, { state: started.state, dryRun: false });
|
|
192
336
|
if (row.error) {
|
|
193
337
|
return fail(`${branch} exists with its brief, but the tracker row was not updated: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
|
|
194
338
|
}
|
|
195
339
|
|
|
196
340
|
const bytes = Buffer.byteLength(packet.packed.text);
|
|
341
|
+
if (session === null) note('note no session: the slice is not leased; a second start will not see this one');
|
|
197
342
|
if (isJson(flags)) {
|
|
198
343
|
emitJson({
|
|
199
344
|
ok: true,
|
|
@@ -204,6 +349,7 @@ async function startSlice(root, id, flags) {
|
|
|
204
349
|
branch,
|
|
205
350
|
worktree: relative(main, worktree),
|
|
206
351
|
brief: { path: relative(main, briefPath), bytes },
|
|
352
|
+
lease,
|
|
207
353
|
state: { from: row.from, to: row.to, changed: row.changed },
|
|
208
354
|
});
|
|
209
355
|
return EXIT.OK;
|
|
@@ -214,18 +360,62 @@ async function startSlice(root, id, flags) {
|
|
|
214
360
|
`branch ${branch}`,
|
|
215
361
|
`worktree ${relative(main, worktree)}`,
|
|
216
362
|
`brief ${relative(main, briefPath)} ${bytes} bytes`,
|
|
363
|
+
lease === null ? 'lease (none)' : `lease slice ${id} as ${session}`,
|
|
217
364
|
`state ${row.from} -> ${row.to}`,
|
|
218
365
|
];
|
|
219
366
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
220
367
|
return EXIT.OK;
|
|
221
368
|
}
|
|
222
369
|
|
|
370
|
+
/**
|
|
371
|
+
* Split the declared gates into the ones that must run and the ones the ledger
|
|
372
|
+
* already proves: a gate with a passing entry on exactly this staged tree, and
|
|
373
|
+
* no later failure for the same gate on the same tree, does not run again.
|
|
374
|
+
* That is the double gate the review verified — the dispatcher, then the
|
|
375
|
+
* commit hook — and the seed of the gate-run cache (review 0003 Q23).
|
|
376
|
+
*
|
|
377
|
+
* Fail-closed throughout: no tree, no ledger, a version mismatch, or an
|
|
378
|
+
* unknown gate all mean the gate runs. An entry recorded under another binary
|
|
379
|
+
* is not evidence for this one, so a pin mismatch disables the cache and
|
|
380
|
+
* routes through `runGates`, which fails on the mismatch itself.
|
|
381
|
+
*/
|
|
382
|
+
async function splitGates(root, declared, manifestDoc) {
|
|
383
|
+
if (!Array.isArray(declared) || declared.length === 0) return { fresh: [], skipped: [], tree: null };
|
|
384
|
+
if (assertVersionPin(manifestDoc)) return { fresh: [...declared], skipped: [], tree: null };
|
|
385
|
+
const stagedTree = await indexTreeId(root).catch(() => null);
|
|
386
|
+
if (stagedTree === null) return { fresh: [...declared], skipped: [], tree: null };
|
|
387
|
+
const { entries } = await readLedger(root).catch(() => ({ entries: [] }));
|
|
388
|
+
const fresh = [];
|
|
389
|
+
const skipped = [];
|
|
390
|
+
for (const name of declared) {
|
|
391
|
+
if (GATES[name] === undefined) {
|
|
392
|
+
fresh.push(name);
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const pass = passEntryForGate(entries, name, stagedTree);
|
|
396
|
+
if (pass === null) fresh.push(name);
|
|
397
|
+
else skipped.push({ name, at: pass.ts });
|
|
398
|
+
}
|
|
399
|
+
return { fresh, skipped, tree: stagedTree };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** A gate result that did not run: the ledger line is the evidence. */
|
|
403
|
+
function cachedResult({ name, at }) {
|
|
404
|
+
return {
|
|
405
|
+
name,
|
|
406
|
+
status: 'passed',
|
|
407
|
+
detail: `already passed on this tree at ${at} (ledger); not re-run`,
|
|
408
|
+
cached: true,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
223
412
|
async function finishSlice(root, id, flags) {
|
|
224
413
|
const mode = writeMode(flags);
|
|
225
414
|
const main = await mainCheckout(root);
|
|
226
415
|
const resolved = await resolveSlice(main, id);
|
|
227
416
|
if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
|
|
228
417
|
const { title, doc } = resolved.slice;
|
|
418
|
+
const { branch, worktree } = places(main, id);
|
|
229
419
|
|
|
230
420
|
const type = String(flags.type ?? 'feat');
|
|
231
421
|
if (!TYPE.test(type)) {
|
|
@@ -245,10 +435,15 @@ async function finishSlice(root, id, flags) {
|
|
|
245
435
|
const apply = `run: ${CLI} slice finish ${id}${test ? ` --test ${JSON.stringify(test)}` : ''} --yes`;
|
|
246
436
|
|
|
247
437
|
const staged = await stagedFiles(root);
|
|
438
|
+
const split = await splitGates(root, declared, manifest.manifest);
|
|
248
439
|
|
|
249
440
|
if (!mode.apply) {
|
|
250
441
|
const row = await planDone(main, id, { sha: null, test, dryRun: true });
|
|
251
442
|
if (row.error) return fail(row.error, EXIT.USAGE, row.remedy ?? `run: ${CLI} plan status`);
|
|
443
|
+
const gateLine =
|
|
444
|
+
declared.length === 0
|
|
445
|
+
? '(none declared)'
|
|
446
|
+
: `${declared.join(', ')}${split.skipped.length > 0 ? ` (already passed on this tree: ${split.skipped.map((s) => s.name).join(', ')})` : ''}`;
|
|
252
447
|
if (isJson(flags)) {
|
|
253
448
|
emitJson({
|
|
254
449
|
ok: true,
|
|
@@ -258,7 +453,7 @@ async function finishSlice(root, id, flags) {
|
|
|
258
453
|
dryRun: true,
|
|
259
454
|
subject,
|
|
260
455
|
staged,
|
|
261
|
-
gates: { event: 'pre-commit', declared },
|
|
456
|
+
gates: { event: 'pre-commit', declared, skipped: split.skipped.map((s) => s.name) },
|
|
262
457
|
state: { from: row.from, to: row.to, changed: row.wouldChange },
|
|
263
458
|
remedy: apply,
|
|
264
459
|
});
|
|
@@ -267,7 +462,7 @@ async function finishSlice(root, id, flags) {
|
|
|
267
462
|
const lines = [
|
|
268
463
|
`repo ${root}`,
|
|
269
464
|
`slice ${id} ${doc}`,
|
|
270
|
-
`gates pre-commit ${
|
|
465
|
+
`gates pre-commit ${gateLine} (run on apply)`,
|
|
271
466
|
`staged ${staged.length} file(s)${staged.length === 0 ? ' (finish refuses without one)' : ''}`,
|
|
272
467
|
`subject ${subject}`,
|
|
273
468
|
`state ${row.from} -> ${row.to}`,
|
|
@@ -277,15 +472,62 @@ async function finishSlice(root, id, flags) {
|
|
|
277
472
|
return EXIT.OK;
|
|
278
473
|
}
|
|
279
474
|
|
|
475
|
+
// The commit lands on the slice branch or not at all: finishing from `main`
|
|
476
|
+
// commits main's tree under the slice's subject, which is the defect this
|
|
477
|
+
// guard closes (review 0003 Q23).
|
|
478
|
+
const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']).catch(() => null);
|
|
479
|
+
if (head !== branch) {
|
|
480
|
+
return fail(
|
|
481
|
+
`not on the slice branch: HEAD is ${head ?? 'unknown'}, expected ${branch}`,
|
|
482
|
+
EXIT.USAGE,
|
|
483
|
+
`move to the slice worktree (${relative(main, worktree)}) and rerun: ${apply}`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
280
487
|
if (staged.length === 0) {
|
|
281
488
|
return fail(`${root} has nothing staged to commit`, EXIT.USAGE, `stage what the slice changed, then rerun: ${apply}`);
|
|
282
489
|
}
|
|
283
490
|
|
|
284
491
|
// The gates the hook would run, run here. This command is the one place a commit
|
|
285
492
|
// happens where a hook could not have stopped it, so it runs the same dispatcher
|
|
286
|
-
// over the same event, in the checkout that holds the staged diff
|
|
287
|
-
|
|
288
|
-
|
|
493
|
+
// over the same event, in the checkout that holds the staged diff — except the
|
|
494
|
+
// gates the ledger already proves for this tree, which are reported, not re-run.
|
|
495
|
+
const manifestForGates =
|
|
496
|
+
split.fresh.length === declared.length
|
|
497
|
+
? manifest.manifest
|
|
498
|
+
: { ...manifest.manifest, gates: { ...(manifest.manifest?.gates ?? {}), preCommit: split.fresh } };
|
|
499
|
+
const freshGates =
|
|
500
|
+
split.fresh.length === 0 && declared.length > 0
|
|
501
|
+
? { event: 'pre-commit', gates: [], failed: false, notes: [] }
|
|
502
|
+
: await runGates('pre-commit', { root, manifest: manifestForGates });
|
|
503
|
+
if (freshGates.error) return fail(freshGates.error, EXIT.USAGE);
|
|
504
|
+
|
|
505
|
+
// A gate that rewrites the tree (the formatter restaging) moves the commit
|
|
506
|
+
// off the tree the skipped gates passed on. Their evidence is stale, so they
|
|
507
|
+
// run once on the new tree; those results stand, the way fresh results do.
|
|
508
|
+
let skipped = split.skipped;
|
|
509
|
+
if (skipped.length > 0 && split.tree !== null) {
|
|
510
|
+
const afterTree = await indexTreeId(root).catch(() => null);
|
|
511
|
+
if (afterTree !== null && afterTree !== split.tree) {
|
|
512
|
+
const { entries } = await readLedger(root).catch(() => ({ entries: [] }));
|
|
513
|
+
const stale = skipped.filter((s) => passEntryForGate(entries, s.name, afterTree) === null);
|
|
514
|
+
if (stale.length > 0) {
|
|
515
|
+
const rerun = await runGates('pre-commit', {
|
|
516
|
+
root,
|
|
517
|
+
manifest: { ...manifest.manifest, gates: { ...(manifest.manifest?.gates ?? {}), preCommit: stale.map((s) => s.name) } },
|
|
518
|
+
});
|
|
519
|
+
if (rerun.error) return fail(rerun.error, EXIT.USAGE);
|
|
520
|
+
freshGates.gates.push(...rerun.gates);
|
|
521
|
+
if (rerun.failed) freshGates.failed = true;
|
|
522
|
+
for (const n of rerun.notes ?? []) freshGates.notes.push(n);
|
|
523
|
+
skipped = skipped.filter((s) => !stale.some((t) => t.name === s.name));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const byName = new Map(freshGates.gates.map((g) => [g.name, g]));
|
|
529
|
+
const combined = declared.map((name) => byName.get(name) ?? cachedResult(skipped.find((s) => s.name === name)));
|
|
530
|
+
const gates = { ...freshGates, gates: combined };
|
|
289
531
|
|
|
290
532
|
if (gates.failed) {
|
|
291
533
|
const failed = gates.gates.filter((g) => g.status === 'failed').map((g) => g.name).join(', ');
|
|
@@ -321,6 +563,12 @@ async function finishSlice(root, id, flags) {
|
|
|
321
563
|
return fail(`${sha} is committed, but the tracker row could not record it: ${row.error}`, EXIT.FAIL, row.remedy ?? `run: ${CLI} plan status`);
|
|
322
564
|
}
|
|
323
565
|
|
|
566
|
+
// The slice is done: its lease goes with it. A failure here is a note — the
|
|
567
|
+
// commit exists, and a lease the TTL will reap is not a second failure.
|
|
568
|
+
const released = await releaseSlice(main, id, sliceSession(flags));
|
|
569
|
+
if (released.error) note(`note the slice lease could not be released (${released.error}); the TTL reaps it`);
|
|
570
|
+
|
|
571
|
+
for (const s of skipped) note(`note ${s.name}: already passed on this tree; not re-run`);
|
|
324
572
|
if (isJson(flags)) {
|
|
325
573
|
emitJson({
|
|
326
574
|
ok: true,
|
|
@@ -351,3 +599,114 @@ async function finishSlice(root, id, flags) {
|
|
|
351
599
|
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
352
600
|
return EXIT.OK;
|
|
353
601
|
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* `simt slice abandon <id>`: take the slice back down. The worktree is
|
|
605
|
+
* removed, the branch deleted, and the tracker row returns to the ready
|
|
606
|
+
* state — abandonment is a state the tracker can see, not a worktree that
|
|
607
|
+
* silently rots. Preview names everything that would be destroyed; `--yes`
|
|
608
|
+
* is the consent to destroy it (review 0003 Q23).
|
|
609
|
+
*/
|
|
610
|
+
async function abandonSlice(root, id, flags) {
|
|
611
|
+
const mode = writeMode(flags);
|
|
612
|
+
const main = await mainCheckout(root);
|
|
613
|
+
const resolved = await resolveSlice(main, id);
|
|
614
|
+
if (resolved.error) return fail(resolved.error, EXIT.USAGE, resolved.remedy);
|
|
615
|
+
const { doc } = resolved.slice;
|
|
616
|
+
const { branch, worktree } = places(main, id);
|
|
617
|
+
const session = sliceSession(flags);
|
|
618
|
+
const apply = `run: ${CLI} slice abandon ${id} --yes`;
|
|
619
|
+
|
|
620
|
+
const loaded = await loadFormat(main);
|
|
621
|
+
if (loaded.error) return fail(loaded.error, EXIT.USAGE, loaded.remedy);
|
|
622
|
+
const readyDisplay = loaded.format.canonical(loaded.format.ready) ?? 'Queued';
|
|
623
|
+
const preview = await planDone(main, id, { state: readyDisplay, dryRun: true });
|
|
624
|
+
if (preview.error) return fail(preview.error, EXIT.USAGE, preview.remedy ?? `run: ${CLI} plan status`);
|
|
625
|
+
|
|
626
|
+
if (!mode.apply) {
|
|
627
|
+
if (isJson(flags)) {
|
|
628
|
+
emitJson({
|
|
629
|
+
ok: true,
|
|
630
|
+
command: 'slice abandon',
|
|
631
|
+
root,
|
|
632
|
+
id,
|
|
633
|
+
dryRun: true,
|
|
634
|
+
branch,
|
|
635
|
+
worktree: relative(main, worktree),
|
|
636
|
+
state: { from: preview.from, to: preview.to, changed: preview.wouldChange },
|
|
637
|
+
remedy: apply,
|
|
638
|
+
});
|
|
639
|
+
return EXIT.OK;
|
|
640
|
+
}
|
|
641
|
+
const lines = [
|
|
642
|
+
`repo ${root}`,
|
|
643
|
+
`slice ${id} ${doc}`,
|
|
644
|
+
`remove ${relative(main, worktree)} and branch ${branch} (on apply)`,
|
|
645
|
+
`state ${preview.from} -> ${preview.to}`,
|
|
646
|
+
`apply ${apply}`,
|
|
647
|
+
];
|
|
648
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
649
|
+
return EXIT.OK;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// Abandoning from inside the worktree would pull the floor out: the shell's
|
|
653
|
+
// own directory is the thing being removed. Compared through git, not through
|
|
654
|
+
// strings, because the caller's spelling of the path may differ in symlinks.
|
|
655
|
+
const top = await git(root, ['rev-parse', '--show-toplevel']).catch(() => null);
|
|
656
|
+
if (top !== null && (top === worktree || top.startsWith(`${worktree}/`))) {
|
|
657
|
+
return fail(
|
|
658
|
+
`cannot abandon ${id} from inside its worktree`,
|
|
659
|
+
EXIT.USAGE,
|
|
660
|
+
`move to the main checkout (${main}) and rerun: ${apply}`,
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const removed = [];
|
|
665
|
+
try {
|
|
666
|
+
await git(main, ['worktree', 'remove', '--force', worktree]);
|
|
667
|
+
removed.push(`removed ${relative(main, worktree)}`);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
|
|
670
|
+
removed.push(`kept ${relative(main, worktree)} (${detail})`);
|
|
671
|
+
}
|
|
672
|
+
try {
|
|
673
|
+
await git(main, ['branch', '-D', branch]);
|
|
674
|
+
removed.push(`deleted ${branch}`);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
const detail = String(error.stderr ?? error.message ?? error).trim().split('\n')[0];
|
|
677
|
+
removed.push(`kept ${branch} (${detail})`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const row = await planDone(main, id, { state: readyDisplay, dryRun: false });
|
|
681
|
+
if (row.error) {
|
|
682
|
+
return fail(
|
|
683
|
+
`the worktree is down, but the tracker row was not updated: ${row.error}`,
|
|
684
|
+
EXIT.FAIL,
|
|
685
|
+
row.remedy ?? `run: ${CLI} plan status`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const released = await releaseSlice(main, id, session);
|
|
690
|
+
const leaseLine =
|
|
691
|
+
released.released === true
|
|
692
|
+
? `released the slice lease`
|
|
693
|
+
: released.error
|
|
694
|
+
? `the slice lease could not be released (${released.error}); the TTL reaps it`
|
|
695
|
+
: 'no slice lease was held';
|
|
696
|
+
if (isJson(flags)) {
|
|
697
|
+
emitJson({
|
|
698
|
+
ok: true,
|
|
699
|
+
command: 'slice abandon',
|
|
700
|
+
root,
|
|
701
|
+
id,
|
|
702
|
+
branch,
|
|
703
|
+
worktree: relative(main, worktree),
|
|
704
|
+
lease: released.released === true ? { slice: id, session } : null,
|
|
705
|
+
state: { from: row.from, to: row.to, changed: row.changed },
|
|
706
|
+
});
|
|
707
|
+
return EXIT.OK;
|
|
708
|
+
}
|
|
709
|
+
const lines = [`repo ${root}`, `slice ${id} ${doc}`, ...removed, `state ${row.from} -> ${row.to}`, `lease ${leaseLine}`];
|
|
710
|
+
emit(lines.join('\n'), { maxBytes: flags.maxBytes ?? 0 });
|
|
711
|
+
return EXIT.OK;
|
|
712
|
+
}
|
package/src/commands/sync.mjs
CHANGED
|
@@ -25,15 +25,14 @@ import { manifestDrift, readManifest, unrunnableGates, buildManifest, writeManif
|
|
|
25
25
|
import { diffField, emitDiffs, readBefore } from '../diff.mjs';
|
|
26
26
|
import { readCapability } from '../hooks/capability.mjs';
|
|
27
27
|
import { surfacesLine } from '../projection/surfaces.mjs';
|
|
28
|
-
import { CLI } from '../name.mjs';
|
|
29
|
-
import { MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
28
|
+
import { CLI, LOCK_FILE, MANIFEST_FILE, MANIFEST_LEGACY } from '../name.mjs';
|
|
30
29
|
import { assertVersionPin, packageVersion } from '../package-version.mjs';
|
|
31
30
|
import { resolvePacks } from '../packs.mjs';
|
|
32
31
|
import { commitJournal, journaledWriteFile } from '../journal.mjs';
|
|
33
32
|
import { buildCacheRecord, hooksSummary, invalidateCheckCache, writeCheckCache } from '../status.mjs';
|
|
34
|
-
import { computeLock, readLock
|
|
33
|
+
import { computeLock, readLock } from '../projection/lock.mjs';
|
|
35
34
|
import { runDeclaredChecks } from '../kernel/checks.mjs';
|
|
36
|
-
import {
|
|
35
|
+
import { decisionsCutoff, decisionsDir } from '../decisions/dir.mjs';
|
|
37
36
|
import { lintDecisions } from '../memory/decide.mjs';
|
|
38
37
|
|
|
39
38
|
/** Findings `sync` reports and does not fail on.
|
|
@@ -64,6 +63,12 @@ export const SYNC_NON_BLOCKING = new Set([
|
|
|
64
63
|
'budget-cut',
|
|
65
64
|
'file-lines-warn',
|
|
66
65
|
'check-unbuilt',
|
|
66
|
+
// Review 0003 Q28: the tsconfig strictness findings. The base file is repo-owned —
|
|
67
|
+
// init no longer writes it, so neither can `sync` — and both findings' remedies are
|
|
68
|
+
// a decision about the repo's own compiler policy, not a missing file. Blocking on
|
|
69
|
+
// them would fail every fresh TypeScript adoption over a file the tool does not write.
|
|
70
|
+
'inert',
|
|
71
|
+
'inconsistent',
|
|
67
72
|
]);
|
|
68
73
|
|
|
69
74
|
/**
|
|
@@ -101,7 +106,21 @@ export const SYNC_NON_BLOCKING = new Set([
|
|
|
101
106
|
// (ADR-083). It says the projection was cut to fit a ceiling this repository set, so the
|
|
102
107
|
// remedy is a decision about the number — and a cut the repository asked for by declaring
|
|
103
108
|
// a budget is the tool working, not a repository in need of a file.
|
|
104
|
-
|
|
109
|
+
// `inert` and `inconsistent` are here for review 0003 Q28: the base file is repo-owned,
|
|
110
|
+
// so the gate reports the state of the repo's own compiler policy without failing it.
|
|
111
|
+
// A fresh TypeScript adoption has neither file in a state the tool can repair, and a
|
|
112
|
+
// gate that reddens on a policy decision is one the repo satisfies by choosing, not by
|
|
113
|
+
// running a command.
|
|
114
|
+
export const CHECK_NON_BLOCKING = new Set([
|
|
115
|
+
'orphaned',
|
|
116
|
+
'pack-unpinned',
|
|
117
|
+
'budget',
|
|
118
|
+
'budget-cut',
|
|
119
|
+
'file-lines-warn',
|
|
120
|
+
'check-unbuilt',
|
|
121
|
+
'inert',
|
|
122
|
+
'inconsistent',
|
|
123
|
+
]);
|
|
105
124
|
|
|
106
125
|
export async function syncCommand(positional, flags) {
|
|
107
126
|
const root = flags.root ?? positional[0] ?? process.cwd();
|
|
@@ -153,6 +172,10 @@ export async function syncCommand(positional, flags) {
|
|
|
153
172
|
);
|
|
154
173
|
}
|
|
155
174
|
|
|
175
|
+
// A dangling cross-reference does not stop the projection: the region renders, and one
|
|
176
|
+
// unreachable reference is a defect to report, not a reason to withhold the rest.
|
|
177
|
+
for (const p of result.crossRefs ?? []) note(`PROBLEM ${p}`);
|
|
178
|
+
|
|
156
179
|
const broken = result.targets.filter((t) => t.action === 'malformed');
|
|
157
180
|
if (broken.length > 0) {
|
|
158
181
|
const first = broken[0];
|
|
@@ -307,23 +330,13 @@ export async function syncCommand(positional, flags) {
|
|
|
307
330
|
// mkdir; `sync` did not, so the first `sync` after a surface was added failed with
|
|
308
331
|
// ENOENT on the second target and had already written the first. The parent is
|
|
309
332
|
// derived from the target, never from the surface table, so a future surface with a
|
|
310
|
-
// deeper path needs no change here.
|
|
333
|
+
// deeper path needs no change here. The lock is one of these targets (Q29): it
|
|
334
|
+
// reaches this loop like every other file, so a deleted lock is a write the plan
|
|
335
|
+
// shows and the journal records.
|
|
311
336
|
await mkdir(dirname(t.path), { recursive: true });
|
|
312
337
|
await journaledWriteFile(root, journal, t.path, t.text);
|
|
313
338
|
note(`wrote ${t.file}`);
|
|
314
339
|
}
|
|
315
|
-
const lock = await computeLock(root, flags);
|
|
316
|
-
const lockText = `${JSON.stringify(lock, null, 2)}\n`;
|
|
317
|
-
let previousLock = null;
|
|
318
|
-
try {
|
|
319
|
-
previousLock = await readFile(join(root, LOCK_FILE), 'utf8');
|
|
320
|
-
} catch (error) {
|
|
321
|
-
if (error.code !== 'ENOENT') throw error;
|
|
322
|
-
}
|
|
323
|
-
if (previousLock !== lockText) {
|
|
324
|
-
await journaledWriteFile(root, journal, join(root, LOCK_FILE), lockText);
|
|
325
|
-
note(`wrote ${LOCK_FILE}`);
|
|
326
|
-
}
|
|
327
340
|
await commitJournal(root, 'sync', journal);
|
|
328
341
|
// The regions moved, so the last `check` no longer describes this tree.
|
|
329
342
|
// Invalidated rather than refreshed: `sync` does not run gates, checkers
|
|
@@ -601,13 +614,15 @@ export async function checkRepo(root, opts = {}) {
|
|
|
601
614
|
const { selected } = select(rules.filter((r) => r.error === null), fp.stack ?? [], fp.facts ?? []);
|
|
602
615
|
findings.push(...(await runDeclaredChecks(root, selected, manifest ?? null)));
|
|
603
616
|
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
617
|
+
// Q55: one resolution shared with `decide --check`, so the two commands
|
|
618
|
+
// cannot disagree about whose records these are or which are historical.
|
|
619
|
+
const relDecisionsDir = await decisionsDir(root);
|
|
620
|
+
const decisionLint = await lintDecisions(join(root, relDecisionsDir), {
|
|
621
|
+
historicalCutoff: await decisionsCutoff(root),
|
|
607
622
|
});
|
|
608
623
|
findings.push(
|
|
609
624
|
...decisionLint.findings.map((f) => ({
|
|
610
|
-
file: [
|
|
625
|
+
file: [relDecisionsDir, f.file].join('/'),
|
|
611
626
|
status: f.status,
|
|
612
627
|
detail: f.detail,
|
|
613
628
|
remedy: f.remedy,
|