@sabaiway/agent-workflow-kit 5.9.0 → 5.11.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 +139 -0
- package/README.md +2 -2
- package/SKILL.md +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +32 -11
- package/bridges/antigravity-cli-bridge/bin/agy-envelope.mjs +160 -0
- package/bridges/antigravity-cli-bridge/bin/agy-envelope.test.mjs +235 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +23 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +242 -38
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +482 -38
- package/bridges/antigravity-cli-bridge/capability.json +3 -2
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +45 -12
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +6 -3
- package/bridges/antigravity-cli-bridge/setup/README.md +18 -5
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/state-block-guard.mjs +107 -45
- package/references/modes/commit-guard.md +11 -8
- package/references/modes/core-evidence.md +1 -1
- package/references/modes/dispatch.md +32 -10
- package/references/modes/set-recipe.md +8 -5
- package/references/modes/state-block-guard.md +39 -31
- package/references/modes/worktrees.md +47 -3
- package/references/scripts/check-docs-size-cli.test.mjs +2 -2
- package/references/shared/report-footer.md +2 -2
- package/references/templates/agent_rules.md +1 -0
- package/tools/advisor-matrix.mjs +165 -0
- package/tools/commands.mjs +2 -2
- package/tools/commit-guard.mjs +74 -17
- package/tools/core-evidence.mjs +10 -0
- package/tools/detect-backends.mjs +1 -0
- package/tools/dispatch-advisor.mjs +323 -0
- package/tools/dispatch.mjs +174 -109
- package/tools/doc-parity.mjs +68 -14
- package/tools/ensure-configs.mjs +4 -4
- package/tools/flow-check-cores.mjs +35 -6
- package/tools/flow-check-rungs.mjs +20 -2
- package/tools/flow-check.mjs +20 -5
- package/tools/lens-region.mjs +13 -1
- package/tools/observation-builder.mjs +123 -0
- package/tools/satellite-locator.mjs +179 -0
- package/tools/source-size-scope.mjs +3 -1
- package/tools/worktree-handoff-return.mjs +369 -0
- package/tools/worktree-prompt.mjs +190 -0
- package/tools/worktrees-record.mjs +171 -0
- package/tools/worktrees.mjs +308 -297
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
short, shellQuote, writerCommand,
|
|
21
21
|
collectUnansweredRedRefusals, collectDegradeCoverageRefusals, collectReceiptCoverageRefusals,
|
|
22
22
|
} from './flow-check-rungs.mjs';
|
|
23
|
+
import { CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
|
|
23
24
|
|
|
24
25
|
// The checker only refuses — park/resume/complete are explicit writer actions (#59). Printed
|
|
25
26
|
// operand shapes: flag values ride the inline --flag='value' form and positionals follow a
|
|
@@ -128,10 +129,21 @@ const deltaRefusals = (records, owner) => {
|
|
|
128
129
|
// degrade after a final-start at the same fingerprint refuses unless a LATER final-start at that
|
|
129
130
|
// fingerprint completed (its `final` record landed after it). The checker reads raw records,
|
|
130
131
|
// never the authoritative selection (#65).
|
|
131
|
-
const degradeOrderingRefusals = (coreRecords) => {
|
|
132
|
+
const degradeOrderingRefusals = (coreRecords, advisories) => {
|
|
132
133
|
const refusals = [];
|
|
134
|
+
let contentFree = 0;
|
|
133
135
|
coreRecords.forEach((r, i) => {
|
|
134
136
|
if (r.kind !== 'degrade') return;
|
|
137
|
+
// A degrade carries no base and no attempt, so on the content-free fingerprint two records
|
|
138
|
+
// from unrelated clean moments pair up and this rung refuses EVERY commit, whatever tree is
|
|
139
|
+
// being judged. Their order is not a fact about any tree, so it decides nothing here. Stated
|
|
140
|
+
// residual: #64 is therefore unenforceable for a degrade minted on a clean tree until the
|
|
141
|
+
// record carries a base — the queued store migration owns that, and a clean tree gates nothing
|
|
142
|
+
// meanwhile.
|
|
143
|
+
if (r.fingerprint === CONTENT_FREE_FINGERPRINT) {
|
|
144
|
+
contentFree += 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
135
147
|
const startedBefore = coreRecords.some((s, j) => j < i && s.kind === 'final-start' && s.fingerprint === r.fingerprint);
|
|
136
148
|
if (!startedBefore) return;
|
|
137
149
|
const cured = coreRecords.some((s, j) => j > i && s.kind === 'final-start' && s.fingerprint === r.fingerprint
|
|
@@ -140,6 +152,9 @@ const degradeOrderingRefusals = (coreRecords) => {
|
|
|
140
152
|
refusals.push(`a core degrade (backend "${r.backend}") landed AFTER a final-start at its fingerprint (${short(r.fingerprint)}) with no later completed re-run at it — degrades mint strictly BEFORE the final run (#64); re-run run-gates.mjs --final on this tree`);
|
|
141
153
|
}
|
|
142
154
|
});
|
|
155
|
+
if (contentFree > 0) {
|
|
156
|
+
advisories.push(`${contentFree} core degrade(s) minted on a CONTENT-FREE tree are outside the ordering rung (#64): the record carries no base, so two clean moments cannot be shown to be one and their order states nothing about any tree`);
|
|
157
|
+
}
|
|
143
158
|
return refusals;
|
|
144
159
|
};
|
|
145
160
|
|
|
@@ -225,7 +240,7 @@ const baseMotionRefusals = (chain, planId, owner, motion) => {
|
|
|
225
240
|
// Phase-1 rungs (#65/#25/#42 — each self-gates on an OWN adoption). Absent inputs keep the decision
|
|
226
241
|
// byte-identical to the Plan-2 checker. `consumer` rides through to the #65 lane split and defaults
|
|
227
242
|
// to the STRICT lane, so a caller that forgets to thread it inherits strictness.
|
|
228
|
-
export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard' }) => {
|
|
243
|
+
export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flow store', corePath = 'the core evidence store', motion = null, evidence = null, consumer = 'commit-guard', treeCarriesBytes = true }) => {
|
|
229
244
|
const refusals = [];
|
|
230
245
|
const advisories = [];
|
|
231
246
|
if (flowRead.readError) refusals.push(`the flow store is unreadable (${flowRead.readError}) — the checker consumes the FULL read-result; inspect ${flowPath} (fail closed)`);
|
|
@@ -243,11 +258,25 @@ export const decideFlowCheck = ({ flowRead, coreRead, owner, flowPath = 'the flo
|
|
|
243
258
|
if (motion != null && plan.integrityClean) refusals.push(...baseMotionRefusals(chain, planId, owner, motion));
|
|
244
259
|
}
|
|
245
260
|
refusals.push(...deltaRefusals(records, owner));
|
|
246
|
-
refusals.push(...degradeOrderingRefusals(coreRead.records));
|
|
261
|
+
refusals.push(...degradeOrderingRefusals(coreRead.records, advisories));
|
|
247
262
|
if (evidence != null) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
263
|
+
// The base-keyed rung always runs — it asks about this BASE's gate history, which a tree with
|
|
264
|
+
// no content does not change. The two FINGERPRINT-keyed rungs do not, when the CALLER states
|
|
265
|
+
// that the tree carries no bytes: their coverage would then be demanded of the one fingerprint
|
|
266
|
+
// every clean moment shares, so whatever they found there was minted by another moment and
|
|
267
|
+
// possibly another base (the symmetry the #65 content-free arm and commit-guard's content-free
|
|
268
|
+
// lanes state — such evidence must decide nothing, in either direction). The fact is DECLARED
|
|
269
|
+
// by the caller rather than derived here: only the caller knows whether it is judging a commit
|
|
270
|
+
// at all, and a checker that keyed it off the fingerprint alone would also silence the rungs
|
|
271
|
+
// for every routine clean-tree check, where they are exactly what the operator asked for. The
|
|
272
|
+
// skip is RECORDED, never silent.
|
|
273
|
+
refusals.push(...collectUnansweredRedRefusals({ flowRecords: records, coreRecords: coreRead.records, currentBase: evidence.tree.base, owner, consumer, currentFingerprint: evidence.tree.fingerprint, advisories }));
|
|
274
|
+
if (!treeCarriesBytes) {
|
|
275
|
+
advisories.push('the caller states this tree carries NO bytes, so the fingerprint-keyed correlations are skipped: degrade coverage (#25) and receipt coverage (#42) here, and the D10 flow-to-final binding at the guard — evidence at a content-free fingerprint belongs to some other clean moment');
|
|
276
|
+
} else {
|
|
277
|
+
refusals.push(...collectDegradeCoverageRefusals({ flowRecords: records, coreRecords: coreRead.records, tree: evidence.tree, owner, backends: evidence.degradeBackends }));
|
|
278
|
+
refusals.push(...collectReceiptCoverageRefusals({ flowRecords: records, receipts: evidence.receipts, tree: evidence.tree, owner, backends: evidence.receiptBackends, declaredPaths: evidence.declaredPaths, refreshCap: evidence.refreshCap }));
|
|
279
|
+
}
|
|
251
280
|
}
|
|
252
281
|
return { refusals, advisories };
|
|
253
282
|
};
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
CHAIN_KIND, canonicalFlowDigest, authoritativeFlowRecords, flowTreeIdentity,
|
|
14
14
|
} from './flow-record.mjs';
|
|
15
15
|
import { resolveRecordReference } from './flow-store.mjs';
|
|
16
|
-
import { authoritativeOfKind, summarizeReviewReceiptsForTree } from './core-evidence.mjs';
|
|
16
|
+
import { authoritativeOfKind, summarizeReviewReceiptsForTree, CONTENT_FREE_FINGERPRINT } from './core-evidence.mjs';
|
|
17
17
|
import { FALLBACK_LENS_ADDITIONAL_ONLY } from './cheap-agents.mjs';
|
|
18
18
|
|
|
19
19
|
export const short = (digest) => `${digest.slice(0, 12)}…`;
|
|
@@ -161,7 +161,21 @@ export const evaluateVetoOverride = ({ records, vetoReceipt, tree }) => {
|
|
|
161
161
|
// runner-attested capability (a one-time unpublished nonce over stdin or an inherited FD, verified
|
|
162
162
|
// against a one-way commitment recorded in the final-start); it needs its own IPC contract and is
|
|
163
163
|
// QUEUED, never pretended here.
|
|
164
|
-
|
|
164
|
+
//
|
|
165
|
+
// The CONTENT-FREE arm (FINGERPRINT-BASE-BINDING, second face): a red final minted while the work
|
|
166
|
+
// tree was CLEAN hashes an empty payload, and that one value is shared by every clean moment of
|
|
167
|
+
// every repository — so the base correlation resolves to as many bases as the store has clean
|
|
168
|
+
// moments and refuses fail-closed forever, on a record that describes no working state at all.
|
|
169
|
+
// The arm is keyed on the FINGERPRINT, not on how many bases happen to correlate: resolving to
|
|
170
|
+
// exactly one base is an accident of store history, never a statement about the tree. It is one
|
|
171
|
+
// half of a symmetry, and it is only sound WITH the other: content-free evidence must decide
|
|
172
|
+
// nothing in either direction, so commit-guard equally refuses to ATTEST from a content-free
|
|
173
|
+
// receipt (commit-guard.mjs, the two content-free lanes) — without that half, stepping over a red
|
|
174
|
+
// here would leave a stale green free to acquit. The skip is RECORDED in the `advisories` sink —
|
|
175
|
+
// a rung that steps over evidence says so. That sink is the ONLY place the skip is observable:
|
|
176
|
+
// decideFlowCheck always passes one, and a caller that omits it (a test, a future consumer) gets
|
|
177
|
+
// the same refusals and no record of the step.
|
|
178
|
+
export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, currentBase, owner, consumer = 'commit-guard', currentFingerprint = null, advisories = [] }) => {
|
|
165
179
|
if (!hasOwnAdoption(flowRecords, owner)) return [];
|
|
166
180
|
const adoptionInstants = flowRecords
|
|
167
181
|
.filter((r) => r.kind === CHAIN_KIND && r.purpose === 'adoption' && r.owner === owner)
|
|
@@ -192,6 +206,10 @@ export const collectUnansweredRedRefusals = ({ flowRecords, coreRecords, current
|
|
|
192
206
|
for (const { r, i } of finals) {
|
|
193
207
|
if (r.status !== 'red') continue;
|
|
194
208
|
if (armingInstant !== null && isCanonicalInstant(r.timestamp) && Date.parse(r.timestamp) < armingInstant) continue;
|
|
209
|
+
if (r.fingerprintBefore === CONTENT_FREE_FINGERPRINT) {
|
|
210
|
+
advisories.push(`a red final (attempt "${r.attempt}") is OUTSIDE the rung: its tree fingerprint ${short(r.fingerprintBefore)} is CONTENT-FREE — a clean work tree emits an empty payload, so the value identifies no working state, correlates to no base, and gates no commit (#65 content-free arm)`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
195
213
|
const bases = basesAt(r.fingerprintBefore);
|
|
196
214
|
if (bases.length === 0) {
|
|
197
215
|
refusals.push(`a red final (attempt "${r.attempt}") cannot be base-correlated: no flow record carries its tree fingerprint ${short(r.fingerprintBefore)} — the zero-base lane is a fail-closed ambiguity (#65); the rung demands exactly ONE base through the flow store`);
|
package/tools/flow-check.mjs
CHANGED
|
@@ -60,7 +60,7 @@ const usageFail = (message) => Object.assign(new Error(message), { exitCode: 2 }
|
|
|
60
60
|
// PREVIOUS one and an in-matrix comparison would make a new green final unreachable. The SAME
|
|
61
61
|
// distinction reaches the #65 unanswered-red rung (its own header states the split): the 'gate'
|
|
62
62
|
// lane also answers a red under a provable in-progress retry, for the same reason.
|
|
63
|
-
export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {} } = {}) => {
|
|
63
|
+
export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', probes = {}, treeCarriesBytes = true } = {}) => {
|
|
64
64
|
const fingerprintProbe = probes.fingerprint ?? computeTreeFingerprint;
|
|
65
65
|
const owner = deriveFlowOwner(cwd);
|
|
66
66
|
if (owner == null) {
|
|
@@ -161,14 +161,29 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
|
|
|
161
161
|
// authoritative completed final at the CURRENT fingerprint, status green FIRST (a newer red
|
|
162
162
|
// is never bypassed by an older green's matching hash — the guard's own red arm refuses it),
|
|
163
163
|
// then the hash comparison.
|
|
164
|
+
// …and NOT when the CALLER states the tree carries no bytes: the receipt it would bind was then
|
|
165
|
+
// found at the one fingerprint every clean moment of every repository shares, so it was minted by
|
|
166
|
+
// some other moment and its projection hash describes that moment's store, not this one.
|
|
167
|
+
// Correlating it would make the outcome depend on which stray clean moment the store recorded
|
|
168
|
+
// last — the same fact the #65 rung and commit-guard's own content-free lanes apply.
|
|
169
|
+
// The probe stays INSIDE this lane: an armed decision already paid for the tree (evidence.tree),
|
|
170
|
+
// and every other lane — the gate consumer, a broken store, an unarmed flow — must stay inert.
|
|
171
|
+
// A hoisted probe would make those lanes read the whole tree and let an unreadable untracked
|
|
172
|
+
// file throw where the answer is otherwise a quiet, healthy no-op.
|
|
164
173
|
const bindingRefusals = [];
|
|
165
|
-
|
|
174
|
+
const completedFinals = consumer === 'commit-guard' && !healthBroken && treeCarriesBytes
|
|
175
|
+
? authoritativeOfKind(coreRead.records, 'final')
|
|
176
|
+
: [];
|
|
177
|
+
// …and only when a final record EXISTS to bind: with none in the store there is no binding to
|
|
178
|
+
// verify, so reading the tree would answer a question nobody asked.
|
|
179
|
+
if (completedFinals.length > 0) {
|
|
166
180
|
const currentFingerprint = evidence?.tree.fingerprint ?? fingerprintProbe(cwd);
|
|
167
181
|
if (currentFingerprint == null) {
|
|
168
182
|
bindingRefusals.push('the current tree fingerprint is unresolvable — the D10 flow binding cannot be verified (fail closed); re-run run-gates.mjs --final on a healthy tree');
|
|
169
183
|
}
|
|
170
|
-
const currentFinal = currentFingerprint == null
|
|
171
|
-
|
|
184
|
+
const currentFinal = currentFingerprint == null
|
|
185
|
+
? undefined
|
|
186
|
+
: completedFinals.find((r) => r.fingerprintBefore === currentFingerprint);
|
|
172
187
|
if (currentFinal !== undefined && currentFinal.status === 'green') {
|
|
173
188
|
const bound = currentFinal.evidenceHashes?.flow;
|
|
174
189
|
if (typeof bound === 'string') {
|
|
@@ -193,7 +208,7 @@ export const computeFlowDecision = ({ cwd = process.cwd(), consumer = 'gate', pr
|
|
|
193
208
|
}
|
|
194
209
|
}
|
|
195
210
|
}
|
|
196
|
-
const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer });
|
|
211
|
+
const { refusals, advisories } = decideFlowCheck({ flowRead, coreRead, owner, flowPath, corePath, motion, evidence, consumer, treeCarriesBytes });
|
|
197
212
|
// Semantic refusals bind only an ARMED store; the D10 binding refusals ride the commit-guard
|
|
198
213
|
// lane UNCONDITIONALLY — a deleted or truncated store must never un-arm the binding.
|
|
199
214
|
const effectiveRefusals = healthBroken ? refusals : [...(armed ? [...refusals, ...evidenceRefusals] : []), ...bindingRefusals];
|
package/tools/lens-region.mjs
CHANGED
|
@@ -49,7 +49,19 @@ Apply this as part of §2 before any user-facing summary:
|
|
|
49
49
|
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.`;
|
|
50
50
|
const COMMS_PRIOR_AD054 = `${COMMS_PRIOR_PRE_AD054}
|
|
51
51
|
- **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
|
|
52
|
-
|
|
52
|
+
// The canon that shipped between the plain-language bullet and the closing-state-block contract.
|
|
53
|
+
// Written out in full rather than composed from the constant above: the plain-language bullet LEADS
|
|
54
|
+
// the block, so an append-based composition would produce a body that never shipped and would then
|
|
55
|
+
// match no deployed file at all.
|
|
56
|
+
const COMMS_PRIOR_PLAIN_LANGUAGE = `### 2.x. Communication (user-facing messages)
|
|
57
|
+
Apply this as part of §2 before any user-facing summary:
|
|
58
|
+
- **Plain language.** User-facing narration is short, clear, plain words of the dialogue language; when the dialogue language is not English, transliterated English jargon is banned — an English term survives only as the NAME of a thing (a flag / command / file / test), glossed in plain words when helpful; plain English stays plain for English-dialogue users.
|
|
59
|
+
- **Deliver the artifact IN the message** — paste the prompt / diff / version / command inline; never "see §X / open the file / run it and you'll see" as a *substitute* for showing what was asked.
|
|
60
|
+
- **Lead with the result**, then the details; show exactly what was asked — no deflection, no "almost done" when the ask was the finished thing.
|
|
61
|
+
- **No condescension, no filler.** Own a miss plainly and fix it in the same message.
|
|
62
|
+
- **Large artifact (≈>100 lines):** deliver a real summary or the key excerpt inline **and** link the file — never flood the reader with a 2000-line paste, never hide the answer behind a bare pointer.
|
|
63
|
+
- **Live host/session facts are tool-composed only.** Any claim about the current host or session state (prompts fired, sandbox scope, whether a bypass was needed, network reachability, approval counts) must trace to **live tool output** from **this session**; a memory/handover snapshot is **context, never report facts**, and a claim with no live signal is **omitted or explicitly marked unverified** — never asserted from recollection.`;
|
|
64
|
+
export const COMMS_PRIORS = [COMMS_PRIOR_PRE_AD054, COMMS_PRIOR_AD054, COMMS_PRIOR_PLAIN_LANGUAGE];
|
|
53
65
|
|
|
54
66
|
const stripCr = (line) => (line.endsWith('\r') ? line.slice(0, -1) : line);
|
|
55
67
|
const isBoundary = (bareLine) => bareLine === '---' || /^#{2,3} /.test(bareLine);
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// observation-builder.mjs — measure a scope and construct the OBSERVATION record (delegation
|
|
2
|
+
// Plan 3, Phase 3). Extracted from dispatch.mjs so `dispatch observe` and the handoff-return rung
|
|
3
|
+
// build the IDENTICAL record through ONE path: the rung is imported by dispatch.mjs for its verb,
|
|
4
|
+
// so a rung importing dispatch.mjs back would be exactly the cycle test/read-graph-purity.test.mjs
|
|
5
|
+
// pins the tools graph against. Every function here moved byte-for-byte; the only new export is
|
|
6
|
+
// the record construction the observe verb previously inlined.
|
|
7
|
+
//
|
|
8
|
+
// Spawns nothing but git READS (through the shared gitLine). No CLI, no writes, no side effects on
|
|
9
|
+
// import. Dependency-free, Node >= 22.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, openSync, closeSync, realpathSync, constants as fsConstants } from 'node:fs';
|
|
12
|
+
import { resolve, sep } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
DELEGATION_SCHEMA_VERSION, computeNumerator, evaluateObservationEligibility,
|
|
15
|
+
} from './dispatch-record.mjs';
|
|
16
|
+
import { lstatNoFollowRead, describeNonRegular } from './fs-read-nofollow.mjs';
|
|
17
|
+
import { gitLine } from './flow-store-read.mjs';
|
|
18
|
+
import { lexicalRepoRelative } from './repo-lex.mjs';
|
|
19
|
+
|
|
20
|
+
// The solo baseline counts each scope object's POST-IMAGE — the bytes on disk after the
|
|
21
|
+
// construction — which is exactly the `new` numerator rule (D6). No exec diff kind enumerates
|
|
22
|
+
// ranges, so a measured observation can never claim a partial object.
|
|
23
|
+
const SOLO_COMPONENT_KIND = 'new';
|
|
24
|
+
|
|
25
|
+
// The scope's anchor is the git TOP-LEVEL, never the caller's cwd: a recorded scope must name the
|
|
26
|
+
// same objects whoever runs the tool from wherever. `null` outside a work tree — a repo-relative
|
|
27
|
+
// domain with no repository has nothing to be relative TO, and falling back to cwd would be a
|
|
28
|
+
// second, incompatible semantics for the same field.
|
|
29
|
+
export const resolveRepoRoot = (cwd) => {
|
|
30
|
+
if (gitLine(['rev-parse', '--is-inside-work-tree'], cwd) !== 'true') return null;
|
|
31
|
+
const top = gitLine(['rev-parse', '--show-toplevel'], cwd);
|
|
32
|
+
return top === null ? null : realpathSync(top);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// The read is no-follow on the LEAF (a symlinked leaf is already refused by name above; O_NOFOLLOW
|
|
36
|
+
// makes a swap between the classification and the read fail loudly rather than counting another
|
|
37
|
+
// object's bytes). Honest limit: classify-then-read is not race-free, and it is not meant to be —
|
|
38
|
+
// the scope is the orchestrator's OWN work tree and the result is a MAGNITUDE, never a store
|
|
39
|
+
// identity, so a pathname race costs a wrong byte count, not a forged record.
|
|
40
|
+
const readObjectBytes = (path) => {
|
|
41
|
+
const fd = openSync(path, (fsConstants.O_RDONLY ?? 0) | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0));
|
|
42
|
+
try {
|
|
43
|
+
return readFileSync(fd);
|
|
44
|
+
} finally {
|
|
45
|
+
closeSync(fd);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// One scope object → one numerator entry. Refuses by NAME on anything it cannot count honestly: a
|
|
50
|
+
// path escaping the repo LEXICALLY, an absent path, a non-regular path (a symlinked leaf included —
|
|
51
|
+
// following one would count another object's bytes under this name), and a path whose REAL location
|
|
52
|
+
// is outside the repository. The last one is the case the lexical rule alone cannot see: it rejects
|
|
53
|
+
// `../x` while accepting `link/x`, where `link` is an ancestor symlink pointing out of the tree.
|
|
54
|
+
// The identity is the CANONICAL repo-relative path taken from the verified real path — not a content
|
|
55
|
+
// hash. The solo domain has no rename lineage for a content id to protect, and a content id would
|
|
56
|
+
// let one object read between two measurements look like TWO objects instead of refusing as the
|
|
57
|
+
// producer contradiction it is ("one identity, one size"). Two equal-byte files at different paths
|
|
58
|
+
// are two objects and count twice; one path reached twice (a second listing, an in-repo ancestor
|
|
59
|
+
// symlink) is one object and counts once.
|
|
60
|
+
export const measureObject = (root, rel) => {
|
|
61
|
+
const lexical = lexicalRepoRelative(rel);
|
|
62
|
+
if (!lexical.ok) return { ok: false, reason: `scope path "${rel}": ${lexical.reason}` };
|
|
63
|
+
const path = resolve(root, rel);
|
|
64
|
+
const stat = lstatNoFollowRead(path);
|
|
65
|
+
if (stat === null) return { ok: false, reason: `scope path "${rel}" does not exist — an observation counts objects that are actually there (fail closed)` };
|
|
66
|
+
if (!stat.isFile()) return { ok: false, reason: `scope path "${rel}" is a ${describeNonRegular(stat)}, not a regular file — the scope names repository objects (fail closed)` };
|
|
67
|
+
const real = realpathSync(path);
|
|
68
|
+
if (!real.startsWith(`${root}${sep}`)) {
|
|
69
|
+
return { ok: false, reason: `scope path "${rel}" resolves to ${real}, which leaves the repository at ${root} — an ancestor symlink is not a way out of the scope domain (fail closed)` };
|
|
70
|
+
}
|
|
71
|
+
const canonical = real.slice(root.length + 1);
|
|
72
|
+
const bytes = readObjectBytes(path);
|
|
73
|
+
return { ok: true, entry: { kind: SOLO_COMPONENT_KIND, path: canonical, objectId: canonical, postImageBytes: bytes.length } };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// One repo-relative path per scope entry, in the order given. The measured CANONICAL paths become
|
|
77
|
+
// the record's `scope` as a canonical JSON array, so what was measured and what is written down are
|
|
78
|
+
// the same statement — and a path carrying a space says so unambiguously.
|
|
79
|
+
export const measureScope = (root, paths) => {
|
|
80
|
+
const entries = [];
|
|
81
|
+
for (const rel of paths) {
|
|
82
|
+
const measured = measureObject(root, rel);
|
|
83
|
+
if (!measured.ok) return measured;
|
|
84
|
+
entries.push(measured.entry);
|
|
85
|
+
}
|
|
86
|
+
const numerator = computeNumerator(entries);
|
|
87
|
+
return numerator.ok
|
|
88
|
+
? { ...numerator, scope: JSON.stringify(entries.map((e) => e.path)) }
|
|
89
|
+
: { ok: false, reason: numerator.reason };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const ratio = (value) => value.toFixed(3);
|
|
93
|
+
|
|
94
|
+
// L is printed ONLY where the metric is eligible: an ineligible metric has a NAMED reason and no
|
|
95
|
+
// ratio at all, and printing a number beside the name is how a silent zero gets read as a
|
|
96
|
+
// measurement.
|
|
97
|
+
export const formatRatio = (metric) => (metric.eligible
|
|
98
|
+
? `L = ${ratio(metric.numeratorBytes / metric.denominatorBytes)} (${metric.numeratorBytes} B / ${metric.denominatorBytes} B)`
|
|
99
|
+
: `L = n/a — INELIGIBLE (${metric.ineligibleReason})`);
|
|
100
|
+
|
|
101
|
+
// The ONE observation-record construction: the eligibility is evaluated here, over the same two
|
|
102
|
+
// numbers the record carries, so a record and its own eligibility can never disagree at the door.
|
|
103
|
+
export const buildObservationRecord = ({ waveId, stepClass, measured, provenance, denominatorBytes, planId, phase, timestamp }) => {
|
|
104
|
+
const eligibility = evaluateObservationEligibility({ numeratorBytes: measured.numeratorBytes, denominatorBytes });
|
|
105
|
+
return {
|
|
106
|
+
schema: DELEGATION_SCHEMA_VERSION,
|
|
107
|
+
kind: 'observation',
|
|
108
|
+
waveId,
|
|
109
|
+
stepClass,
|
|
110
|
+
scope: measured.scope,
|
|
111
|
+
metric: {
|
|
112
|
+
numeratorBytes: measured.numeratorBytes,
|
|
113
|
+
denominatorBytes,
|
|
114
|
+
components: measured.components,
|
|
115
|
+
provenance,
|
|
116
|
+
eligible: eligibility.eligible,
|
|
117
|
+
ineligibleReason: eligibility.ineligibleReason,
|
|
118
|
+
},
|
|
119
|
+
planId,
|
|
120
|
+
phase,
|
|
121
|
+
timestamp,
|
|
122
|
+
};
|
|
123
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// satellite-locator.mjs — slug → satellite worktree, and the proof that the handoff found there IS
|
|
2
|
+
// that satellite's identity (delegation Plan 3, Phase 2).
|
|
3
|
+
//
|
|
4
|
+
// Extracted out of worktrees.mjs so BOTH modes can ask the question: `worktrees prompt` composes a
|
|
5
|
+
// satellite's cold-start prompt today, and the dispatch-side handoff-return rung — a later phase,
|
|
6
|
+
// not yet wired — will read what came back through this same leaf. That rung is a `dispatch` verb,
|
|
7
|
+
// so this leaf is what will keep the 3200-line worktrees tool out of the dispatch CLI's closure.
|
|
8
|
+
//
|
|
9
|
+
// READ-ONLY by construction: it writes nothing, spawns nothing, and — deliberately — CONTENT-READS
|
|
10
|
+
// nothing of its own. The git runner and the fs seams are INJECTED, so a caller decides what may
|
|
11
|
+
// run, and the content read arrives as `fs.readFileNoFollow(abs)` returning the family's structured
|
|
12
|
+
// outcome { bytes } | { absent } | { unsafe } | { error: code }. That is not fastidiousness: the ONE
|
|
13
|
+
// no-follow read door lives in worktrees.mjs, its single body is pinned there by a tripwire, and a
|
|
14
|
+
// second body here would be exactly the duplication that pin exists to prevent. `fs` is the shape
|
|
15
|
+
// worktrees.mjs builds: { lstat, readdir, realpath, readFileNoFollow }.
|
|
16
|
+
//
|
|
17
|
+
// Node built-ins plus two pure leaves. No side effects on import; no CLI. Dependency-free, Node >= 22.
|
|
18
|
+
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { PLANS_REL } from './plan-files.mjs';
|
|
21
|
+
import { stop, handoffBasename, parseProvisionRecord, displayValue } from './worktrees-record.mjs';
|
|
22
|
+
|
|
23
|
+
// Every refusal below is the worktrees STOP, unchanged — a caller that reached this leaf directly
|
|
24
|
+
// (the dispatch side does) must be able to recognize it without importing worktrees.mjs.
|
|
25
|
+
export { WORKTREES_STOP } from './worktrees-record.mjs';
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_BRANCH_PREFIX = 'aw/';
|
|
28
|
+
|
|
29
|
+
export const parseWorktreeList = (text) => {
|
|
30
|
+
const entries = [];
|
|
31
|
+
let fields = [];
|
|
32
|
+
const finishEntry = () => {
|
|
33
|
+
if (fields.length === 0) return;
|
|
34
|
+
const entry = { path: null, head: null, branch: null, detached: false, prunable: false, bare: false };
|
|
35
|
+
for (const field of fields) {
|
|
36
|
+
if (field.startsWith('worktree ')) entry.path = field.slice('worktree '.length);
|
|
37
|
+
else if (field.startsWith('HEAD ')) entry.head = field.slice('HEAD '.length);
|
|
38
|
+
else if (field.startsWith('branch ')) entry.branch = field.slice('branch '.length);
|
|
39
|
+
else if (field === 'detached') entry.detached = true;
|
|
40
|
+
else if (field === 'bare') entry.bare = true;
|
|
41
|
+
else if (field === 'prunable' || field.startsWith('prunable ')) entry.prunable = true;
|
|
42
|
+
}
|
|
43
|
+
if (entry.path !== null) entries.push(entry);
|
|
44
|
+
fields = [];
|
|
45
|
+
};
|
|
46
|
+
for (const field of String(text).split('\0')) {
|
|
47
|
+
if (field === '') finishEntry();
|
|
48
|
+
else fields.push(field);
|
|
49
|
+
}
|
|
50
|
+
finishEntry();
|
|
51
|
+
return entries;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const listWorktrees = (git, cwd) => {
|
|
55
|
+
const r = git(['worktree', 'list', '--porcelain', '-z'], cwd);
|
|
56
|
+
if (r.status !== 0) throw stop(`git worktree list failed: ${r.stderr.trim() || r.stdout.trim()}`);
|
|
57
|
+
return parseWorktreeList(r.stdout);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const classifyNodeNoFollow = (path, fs) => {
|
|
61
|
+
const node = (() => {
|
|
62
|
+
try {
|
|
63
|
+
return { stat: fs.lstat(path) };
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return error?.code === 'ENOENT'
|
|
66
|
+
? { stat: null }
|
|
67
|
+
: { error: error?.code ?? 'fs error' };
|
|
68
|
+
}
|
|
69
|
+
})();
|
|
70
|
+
if (node.error) return { kind: 'error', error: node.error };
|
|
71
|
+
if (node.stat === null) return { kind: 'absent' };
|
|
72
|
+
if (!node.stat.isSymbolicLink()) {
|
|
73
|
+
if (node.stat.isDirectory()) return { kind: 'plain-directory', stat: node.stat };
|
|
74
|
+
if (node.stat.isFile()) return { kind: 'regular-file', stat: node.stat };
|
|
75
|
+
return { kind: 'special', stat: node.stat };
|
|
76
|
+
}
|
|
77
|
+
const realPath = (() => {
|
|
78
|
+
try {
|
|
79
|
+
return { path: fs.realpath(path) };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { error: error?.code ?? 'fs error' };
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
if (realPath.error) return { kind: 'symlink-unresolvable', error: realPath.error };
|
|
85
|
+
const target = (() => {
|
|
86
|
+
try {
|
|
87
|
+
return { stat: fs.lstat(realPath.path) };
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return { error: error?.code ?? 'fs error' };
|
|
90
|
+
}
|
|
91
|
+
})();
|
|
92
|
+
if (target.error) return { kind: 'symlink-unresolvable', error: target.error };
|
|
93
|
+
if (target.stat.isDirectory()) return { kind: 'symlink-to-directory', realPath: realPath.path, stat: node.stat };
|
|
94
|
+
if (target.stat.isFile()) return { kind: 'symlink-to-file', realPath: realPath.path, stat: node.stat };
|
|
95
|
+
return { kind: 'symlink-to-special', realPath: realPath.path, stat: node.stat };
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Whole-chain no-follow: the worktree root, docs, and docs/plans must be plain directories;
|
|
99
|
+
// handoff candidates count ONLY as regular files. states: ok | absent | unreadable.
|
|
100
|
+
// ANY stat failure (not just readdir) renders honestly — list must never crash on a bad node.
|
|
101
|
+
export const scanPlansDir = ({ wtRoot, fs }) => {
|
|
102
|
+
if (classifyNodeNoFollow(wtRoot, fs).kind !== 'plain-directory') return { state: 'unreadable' };
|
|
103
|
+
const docs = classifyNodeNoFollow(join(wtRoot, 'docs'), fs);
|
|
104
|
+
if (docs.kind === 'absent') return { state: 'absent' };
|
|
105
|
+
if (docs.kind !== 'plain-directory') return { state: 'unreadable' };
|
|
106
|
+
const plans = classifyNodeNoFollow(join(wtRoot, PLANS_REL), fs);
|
|
107
|
+
if (plans.kind === 'absent') return { state: 'absent' };
|
|
108
|
+
if (plans.kind !== 'plain-directory') return { state: 'unreadable' };
|
|
109
|
+
let names;
|
|
110
|
+
try {
|
|
111
|
+
names = fs.readdir(join(wtRoot, PLANS_REL));
|
|
112
|
+
} catch {
|
|
113
|
+
return { state: 'unreadable' };
|
|
114
|
+
}
|
|
115
|
+
const handoffs = [];
|
|
116
|
+
const nonRegular = [];
|
|
117
|
+
for (const n of names) {
|
|
118
|
+
if (!/^handoff-.+\.md$/.test(n)) continue;
|
|
119
|
+
const cand = classifyNodeNoFollow(join(wtRoot, PLANS_REL, n), fs);
|
|
120
|
+
if (cand.kind !== 'regular-file') nonRegular.push(n);
|
|
121
|
+
else handoffs.push(n);
|
|
122
|
+
}
|
|
123
|
+
return { state: 'ok', handoffs, nonRegular };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const branchNameOf = (entry) => entry.branch?.replace(/^refs\/heads\//, '') ?? null;
|
|
127
|
+
|
|
128
|
+
export const findSatelliteEntry = ({ root, slug, branch, git, fs }) => {
|
|
129
|
+
const entries = listWorktrees(git, root).slice(1);
|
|
130
|
+
const exactHandoff = [];
|
|
131
|
+
for (const entry of entries) {
|
|
132
|
+
if (entry.prunable) continue;
|
|
133
|
+
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
134
|
+
if (scan.state === 'ok' && scan.handoffs.includes(handoffBasename(slug))) exactHandoff.push(entry);
|
|
135
|
+
}
|
|
136
|
+
if (exactHandoff.length > 1) {
|
|
137
|
+
throw stop(`multiple worktrees carry ${handoffBasename(slug)} — cleanup the duplicate identity before continuing`);
|
|
138
|
+
}
|
|
139
|
+
if (branch !== null) {
|
|
140
|
+
const byBranch = entries.filter((entry) => entry.branch === `refs/heads/${branch}`);
|
|
141
|
+
if (byBranch.length > 1) throw stop(`multiple worktrees claim branch ${branch}`);
|
|
142
|
+
if (byBranch.length === 1) return byBranch[0];
|
|
143
|
+
}
|
|
144
|
+
if (exactHandoff.length === 1) return exactHandoff[0];
|
|
145
|
+
const fallback = entries.filter((entry) => entry.branch === `refs/heads/${DEFAULT_BRANCH_PREFIX}${slug}`);
|
|
146
|
+
if (fallback.length === 1) return fallback[0];
|
|
147
|
+
throw stop(`no registered satellite worktree for ${slug}`);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const readSatelliteIdentity = ({ entry, slug, expectedBranch, fs, abandon = false }) => {
|
|
151
|
+
const name = handoffBasename(slug);
|
|
152
|
+
const scan = scanPlansDir({ wtRoot: entry.path, fs });
|
|
153
|
+
if (scan.state === 'ok' && scan.nonRegular.includes(name)) {
|
|
154
|
+
throw stop(`handoff identity mismatch: ${name} is not a regular file`);
|
|
155
|
+
}
|
|
156
|
+
if (scan.state !== 'ok' || !scan.handoffs.includes(name)) {
|
|
157
|
+
if (abandon) throw stop(`${name} is absent — force deletion is forbidden without the handoff identity`);
|
|
158
|
+
throw stop(`handoff identity mismatch: expected ${name} in the satellite`);
|
|
159
|
+
}
|
|
160
|
+
// Every value below reaches a terminal, and every one of them is foreign: the names come from a
|
|
161
|
+
// directory listing, the record fields from a hand-editable file. They render ESCAPED — the guard
|
|
162
|
+
// that refuses them for the prompt must not be undone by the message that reports them.
|
|
163
|
+
if (scan.handoffs.length !== 1) {
|
|
164
|
+
throw stop(`handoff identity mismatch: expected exactly ${name}, found [${scan.handoffs.map(displayValue).join(', ')}]`);
|
|
165
|
+
}
|
|
166
|
+
const leaf = fs.readFileNoFollow(join(entry.path, PLANS_REL, name));
|
|
167
|
+
if (!leaf.bytes) throw stop(`handoff identity mismatch: ${name} is not readable as a regular file`);
|
|
168
|
+
const record = parseProvisionRecord(String(leaf.bytes));
|
|
169
|
+
const liveBranch = branchNameOf(entry);
|
|
170
|
+
const wantedBranch = expectedBranch ?? liveBranch;
|
|
171
|
+
if (record.slug !== slug || record.branch !== wantedBranch || liveBranch !== wantedBranch) {
|
|
172
|
+
throw stop(
|
|
173
|
+
`handoff identity mismatch: expected slug ${slug} and branch ${displayValue(wantedBranch)}; ` +
|
|
174
|
+
`record has slug ${record.slug === null ? '(missing)' : displayValue(record.slug)} and branch ${record.branch === null ? '(missing)' : displayValue(record.branch)}, ` +
|
|
175
|
+
`live branch ${liveBranch === null ? '(detached)' : displayValue(liveBranch)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return { record, path: join(entry.path, PLANS_REL, name), branch: wantedBranch };
|
|
179
|
+
};
|
|
@@ -31,6 +31,8 @@ const GITLINK_MODE = '160000';
|
|
|
31
31
|
// Raw bytes in, raw bytes out: `ls-files -s -z` emits mode, object and stage, then a TAB, then the
|
|
32
32
|
// path, then a NUL — and the path half is never decoded before it has been matched, so a name
|
|
33
33
|
// carrying a tab or a newline survives intact (splitting on lines would mangle it).
|
|
34
|
+
// The object id is carried through rather than dropped: the record already holds it, and a consumer
|
|
35
|
+
// that must read what the index HOLDS (not what the worktree happens to show) needs exactly that.
|
|
34
36
|
const parseIndexEntries = (buf) => {
|
|
35
37
|
const entries = [];
|
|
36
38
|
let start = 0;
|
|
@@ -41,7 +43,7 @@ const parseIndexEntries = (buf) => {
|
|
|
41
43
|
const tab = record.indexOf(TAB);
|
|
42
44
|
if (tab !== -1) {
|
|
43
45
|
const head = record.subarray(0, tab).toString('utf8').split(' ');
|
|
44
|
-
entries.push({ mode: head[0], stage: Number(head[2]), path: record.subarray(tab + 1) });
|
|
46
|
+
entries.push({ mode: head[0], sha: head[1], stage: Number(head[2]), path: record.subarray(tab + 1) });
|
|
45
47
|
}
|
|
46
48
|
start = end + 1;
|
|
47
49
|
}
|