approval-md 0.2.0 → 0.3.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/README.md +63 -24
- package/SPEC.md +57 -11
- package/dist/src/channels/contract.d.ts +34 -1
- package/dist/src/channels/contract.js +200 -7
- package/dist/src/channels/contract.js.map +1 -1
- package/dist/src/channels/telegram.d.ts +123 -11
- package/dist/src/channels/telegram.js +218 -23
- package/dist/src/channels/telegram.js.map +1 -1
- package/dist/src/channels/web.d.ts +9 -0
- package/dist/src/channels/web.js +17 -0
- package/dist/src/channels/web.js.map +1 -1
- package/dist/src/cli/amend.js +214 -30
- package/dist/src/cli/amend.js.map +1 -1
- package/dist/src/cli/attest.d.ts +9 -0
- package/dist/src/cli/attest.js +134 -7
- package/dist/src/cli/attest.js.map +1 -1
- package/dist/src/cli/channel-telegram.d.ts +99 -26
- package/dist/src/cli/channel-telegram.js +311 -13
- package/dist/src/cli/channel-telegram.js.map +1 -1
- package/dist/src/cli/channel.d.ts +9 -0
- package/dist/src/cli/channel.js +9 -0
- package/dist/src/cli/channel.js.map +1 -1
- package/dist/src/cli/codex-bridge.d.ts +819 -0
- package/dist/src/cli/codex-bridge.js +1607 -0
- package/dist/src/cli/codex-bridge.js.map +1 -0
- package/dist/src/cli/codex.d.ts +1 -1
- package/dist/src/cli/codex.js +304 -7
- package/dist/src/cli/codex.js.map +1 -1
- package/dist/src/cli/daemon.js +4 -1
- package/dist/src/cli/daemon.js.map +1 -1
- package/dist/src/cli/doctor.js +467 -12
- package/dist/src/cli/doctor.js.map +1 -1
- package/dist/src/cli/execute.js +25 -2
- package/dist/src/cli/execute.js.map +1 -1
- package/dist/src/cli/help.d.ts +6 -2
- package/dist/src/cli/help.js +165 -60
- package/dist/src/cli/help.js.map +1 -1
- package/dist/src/cli/hook-codex.d.ts +49 -1
- package/dist/src/cli/hook-codex.js +60 -1
- package/dist/src/cli/hook-codex.js.map +1 -1
- package/dist/src/cli/hook.d.ts +459 -3
- package/dist/src/cli/hook.js +1062 -114
- package/dist/src/cli/hook.js.map +1 -1
- package/dist/src/cli/import.js +1 -1
- package/dist/src/cli/import.js.map +1 -1
- package/dist/src/cli/main.js +5 -3
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/policy-apply.d.ts +195 -0
- package/dist/src/cli/policy-apply.js +573 -0
- package/dist/src/cli/policy-apply.js.map +1 -0
- package/dist/src/cli/policy.js +14 -1
- package/dist/src/cli/policy.js.map +1 -1
- package/dist/src/cli/preflight.d.ts +151 -13
- package/dist/src/cli/preflight.js +398 -41
- package/dist/src/cli/preflight.js.map +1 -1
- package/dist/src/cli/sandbox.js +17 -1
- package/dist/src/cli/sandbox.js.map +1 -1
- package/dist/src/cli/scaffold.d.ts +1 -1
- package/dist/src/cli/scaffold.js +1 -1
- package/dist/src/cli/setup-channel.d.ts +9 -0
- package/dist/src/cli/setup-channel.js +28 -1
- package/dist/src/cli/setup-channel.js.map +1 -1
- package/dist/src/cli/setup-common.d.ts +3 -1
- package/dist/src/cli/setup-common.js +3 -2
- package/dist/src/cli/setup-common.js.map +1 -1
- package/dist/src/cli/setup.d.ts +2 -0
- package/dist/src/cli/setup.js +94 -2
- package/dist/src/cli/setup.js.map +1 -1
- package/dist/src/cli/up.js +115 -51
- package/dist/src/cli/up.js.map +1 -1
- package/dist/src/cli/values.js +3 -4
- package/dist/src/cli/values.js.map +1 -1
- package/dist/src/cli/verb-registry.js +174 -9
- package/dist/src/cli/verb-registry.js.map +1 -1
- package/dist/src/cli/wordmark.d.ts +2 -2
- package/dist/src/cli/wordmark.js +2 -2
- package/dist/src/codex/broker.d.ts +229 -0
- package/dist/src/codex/broker.js +548 -0
- package/dist/src/codex/broker.js.map +1 -0
- package/dist/src/codex/runner.d.ts +178 -0
- package/dist/src/codex/runner.js +231 -0
- package/dist/src/codex/runner.js.map +1 -0
- package/dist/src/codex/serve.d.ts +56 -0
- package/dist/src/codex/serve.js +98 -0
- package/dist/src/codex/serve.js.map +1 -0
- package/dist/src/codex/workspace-commit.d.ts +219 -0
- package/dist/src/codex/workspace-commit.js +549 -0
- package/dist/src/codex/workspace-commit.js.map +1 -0
- package/dist/src/core/advance-cycle.d.ts +51 -0
- package/dist/src/core/advance-cycle.js +66 -2
- package/dist/src/core/advance-cycle.js.map +1 -1
- package/dist/src/core/agents-md.d.ts +20 -18
- package/dist/src/core/agents-md.js +33 -31
- package/dist/src/core/agents-md.js.map +1 -1
- package/dist/src/core/attest.d.ts +215 -0
- package/dist/src/core/attest.js +317 -7
- package/dist/src/core/attest.js.map +1 -1
- package/dist/src/core/audit.d.ts +18 -0
- package/dist/src/core/audit.js +13 -0
- package/dist/src/core/audit.js.map +1 -1
- package/dist/src/core/channel-owner.d.ts +213 -0
- package/dist/src/core/channel-owner.js +358 -0
- package/dist/src/core/channel-owner.js.map +1 -0
- package/dist/src/core/command-class.d.ts +154 -0
- package/dist/src/core/command-class.js +673 -20
- package/dist/src/core/command-class.js.map +1 -1
- package/dist/src/core/commit-guard.d.ts +272 -0
- package/dist/src/core/commit-guard.js +424 -0
- package/dist/src/core/commit-guard.js.map +1 -0
- package/dist/src/core/daemon-actor.d.ts +45 -0
- package/dist/src/core/daemon-actor.js +54 -0
- package/dist/src/core/daemon-actor.js.map +1 -0
- package/dist/src/core/dark-session.d.ts +109 -8
- package/dist/src/core/dark-session.js +266 -82
- package/dist/src/core/dark-session.js.map +1 -1
- package/dist/src/core/decision-refusal.d.ts +23 -2
- package/dist/src/core/decision-refusal.js +24 -2
- package/dist/src/core/decision-refusal.js.map +1 -1
- package/dist/src/core/env-file.d.ts +5 -0
- package/dist/src/core/env-file.js +60 -1
- package/dist/src/core/env-file.js.map +1 -1
- package/dist/src/core/execute.d.ts +15 -2
- package/dist/src/core/execute.js +15 -2
- package/dist/src/core/execute.js.map +1 -1
- package/dist/src/core/gate.d.ts +86 -1
- package/dist/src/core/gate.js +81 -1
- package/dist/src/core/gate.js.map +1 -1
- package/dist/src/core/gesture-refusal.d.ts +166 -0
- package/dist/src/core/gesture-refusal.js +188 -0
- package/dist/src/core/gesture-refusal.js.map +1 -0
- package/dist/src/core/harness-version.d.ts +1 -1
- package/dist/src/core/harness-version.js +3 -1
- package/dist/src/core/harness-version.js.map +1 -1
- package/dist/src/core/instance.d.ts +59 -2
- package/dist/src/core/instance.js +113 -0
- package/dist/src/core/instance.js.map +1 -1
- package/dist/src/core/log.d.ts +39 -1
- package/dist/src/core/log.js.map +1 -1
- package/dist/src/core/policy-explain.d.ts +10 -0
- package/dist/src/core/policy-explain.js +32 -0
- package/dist/src/core/policy-explain.js.map +1 -1
- package/dist/src/core/policy-load.d.ts +41 -1
- package/dist/src/core/policy-load.js +21 -3
- package/dist/src/core/policy-load.js.map +1 -1
- package/dist/src/core/policy-match.d.ts +43 -0
- package/dist/src/core/policy-match.js +52 -0
- package/dist/src/core/policy-match.js.map +1 -1
- package/dist/src/core/policy-proposal.d.ts +52 -0
- package/dist/src/core/policy-proposal.js +102 -2
- package/dist/src/core/policy-proposal.js.map +1 -1
- package/dist/src/core/protected-path-guard.d.ts +117 -4
- package/dist/src/core/protected-path-guard.js +362 -48
- package/dist/src/core/protected-path-guard.js.map +1 -1
- package/dist/src/core/question-preempted.d.ts +141 -0
- package/dist/src/core/question-preempted.js +152 -0
- package/dist/src/core/question-preempted.js.map +1 -0
- package/dist/src/core/read-scope.d.ts +172 -0
- package/dist/src/core/read-scope.js +252 -0
- package/dist/src/core/read-scope.js.map +1 -0
- package/dist/src/core/sandbox.d.ts +81 -0
- package/dist/src/core/sandbox.js +190 -1
- package/dist/src/core/sandbox.js.map +1 -1
- package/dist/src/core/sender-identity.d.ts +476 -0
- package/dist/src/core/sender-identity.js +572 -0
- package/dist/src/core/sender-identity.js.map +1 -0
- package/dist/src/core/shlex.d.ts +102 -0
- package/dist/src/core/shlex.js +159 -0
- package/dist/src/core/shlex.js.map +1 -0
- package/dist/src/core/values.d.ts +18 -8
- package/dist/src/core/values.js +36 -1
- package/dist/src/core/values.js.map +1 -1
- package/dist/src/daemon/advance.d.ts +10 -0
- package/dist/src/daemon/advance.js +25 -4
- package/dist/src/daemon/advance.js.map +1 -1
- package/dist/src/daemon/daemon.js +9 -0
- package/dist/src/daemon/daemon.js.map +1 -1
- package/dist/src/daemon/git-evidence.d.ts +2 -2
- package/dist/src/daemon/git-evidence.js +1 -1
- package/dist/src/mcp/server.js +8 -0
- package/dist/src/mcp/server.js.map +1 -1
- package/docs/cli-reference.md +932 -32
- package/docs/codex-enforced-session.md +75 -2
- package/docs/codex-workspace-broker.md +118 -0
- package/package.json +3 -1
- package/schema/event.schema.json +538 -9
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
- package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
- package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
- package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
- package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
- package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
- package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
- package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
- package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
- package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
- package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
- package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
- package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
- package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
- package/schema/fixtures/policy/valid/canonical.json +1 -1
- package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
- package/schema/fixtures/policy-md/valid/canonical.md +1 -1
- package/schema/fixtures/policy-md/valid/with-values.md +5 -7
- package/schema/fixtures/values/invalid/class-shaped.json +1 -1
- package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
- package/schema/fixtures/values/invalid/non-string-item.json +1 -1
- package/schema/fixtures/values/invalid/over-cap.json +1 -1
- package/schema/fixtures/values/invalid/unknown-key.json +1 -1
- package/schema/fixtures/values/invalid/version-float.json +1 -0
- package/schema/fixtures/values/invalid/version-integer.json +1 -0
- package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
- package/schema/fixtures/values/valid/empty-lists.json +2 -3
- package/schema/fixtures/values/valid/full.json +5 -7
- package/schema/fixtures/values/valid/minimal.json +1 -1
- package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
- package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
- package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
- package/schema/fixtures/values-md/invalid/version-1.md +69 -0
- package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
- package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
- package/schema/fixtures/values-md/valid/absent.md +1 -1
- package/schema/fixtures/values-md/valid/with-values.md +5 -7
- package/schema/policy.schema.json +54 -2
- package/schema/values.schema.json +7 -11
- package/schema/fixtures/values/invalid/version-string.json +0 -1
|
@@ -15,19 +15,45 @@
|
|
|
15
15
|
*
|
|
16
16
|
* ## What it is allowed to do
|
|
17
17
|
*
|
|
18
|
-
* Read git, and at most
|
|
19
|
-
*
|
|
18
|
+
* Read git, and at most four writes: a `--ff-only` merge, `npm run build`,
|
|
19
|
+
* when the merge refused over an untracked file under `backlog/tasks/`
|
|
20
20
|
* that main already contains, clearing that file out of the way (APRV-300, and
|
|
21
|
-
* the rules it obeys are in {@link reconcileUntrackedTaskFiles})
|
|
21
|
+
* the rules it obeys are in {@link reconcileUntrackedTaskFiles}), and the one
|
|
22
|
+
* `approval log sync` performs on its behalf (APRV-346, below).
|
|
22
23
|
*
|
|
23
24
|
* It never resets, never stashes, never checks anything out, and never touches
|
|
24
|
-
* the working log. That list is not conservatism for its own sake — it is
|
|
25
|
-
* of 2026-08-20 (APRV-104's notes, and the reason `approval log sync`
|
|
26
|
-
* all): a working `events.jsonl` rewound through git underneath a live
|
|
27
|
-
* is two chains where there was one. `--ff-only` cannot rewind a file
|
|
28
|
-
* upstream did not change, and when upstream DID change it while the
|
|
29
|
-
* copy is dirty,
|
|
30
|
-
*
|
|
25
|
+
* the working log ITSELF. That list is not conservatism for its own sake — it is
|
|
26
|
+
* fork 2 of 2026-08-20 (APRV-104's notes, and the reason `approval log sync`
|
|
27
|
+
* exists at all): a working `events.jsonl` rewound through git underneath a live
|
|
28
|
+
* appender is two chains where there was one. `--ff-only` cannot rewind a file
|
|
29
|
+
* that upstream did not change, and when upstream DID change it while the
|
|
30
|
+
* working copy is dirty, the file is moved by `cli/log-sync.ts` and by nothing
|
|
31
|
+
* here.
|
|
32
|
+
*
|
|
33
|
+
* ## The routine collision, and the one that is not (APRV-346)
|
|
34
|
+
*
|
|
35
|
+
* Every records advance moves `origin/main`'s `.approval/log/events.jsonl`
|
|
36
|
+
* while the hook keeps appending locally, so "upstream changed the log and so
|
|
37
|
+
* did this working copy" is the NORMAL state of the primary checkout after a
|
|
38
|
+
* merge rather than an edge case. Refusing it sent the operator to `approval log
|
|
39
|
+
* sync` and then back to `approval up` every single time, which is a two-step
|
|
40
|
+
* ritual for a state the preflight can already tell apart from a fork.
|
|
41
|
+
*
|
|
42
|
+
* So the collision is now a question rather than a verdict: is the working log a
|
|
43
|
+
* byte-for-byte EXTENSION of the committed one (main's records 1..N unchanged,
|
|
44
|
+
* local N+1.. following) or are they two chains that share a prefix and then
|
|
45
|
+
* differ? {@link planLogSync} asks `core/log-reconcile.ts` — the same comparison
|
|
46
|
+
* `log sync` and doctor's `log-drift` ask — and answers a plan or `null`:
|
|
47
|
+
*
|
|
48
|
+
* - a plan, and {@link runPreflight} calls `logSync` itself. Not a
|
|
49
|
+
* reimplementation of it: the APRV-215 ceremony (one hold of the append lock,
|
|
50
|
+
* snapshot, baseline, fast-forward, reconcile, rebuild the projections,
|
|
51
|
+
* post-verify) stays the single implementation, and this module supplies a
|
|
52
|
+
* caller rather than a copy;
|
|
53
|
+
* - `null`, and the old `up-preflight-log-diverged` refusal stands unchanged.
|
|
54
|
+
* A fork, a log that does not verify, a log at some path other than the
|
|
55
|
+
* repository's own, or any OTHER upstream-touched path locally modified all
|
|
56
|
+
* answer `null`. Every one of those is a judgment, and this module makes none.
|
|
31
57
|
*
|
|
32
58
|
* ## Refusals, not repairs
|
|
33
59
|
*
|
|
@@ -38,9 +64,11 @@
|
|
|
38
64
|
* commits exist that the remote does not have. A fast-forward is not the
|
|
39
65
|
* operation for that state, and guessing which side to keep is a decision.
|
|
40
66
|
* - `up-preflight-log-diverged` — the upstream range changes the working log or
|
|
41
|
-
* the queue projection,
|
|
42
|
-
*
|
|
43
|
-
*
|
|
67
|
+
* the queue projection, the working copy has uncommitted changes to them, AND
|
|
68
|
+
* the two chains are not in a prefix relationship (or cannot be compared at
|
|
69
|
+
* all). This is the case the human could not judge by eye, and it is `approval
|
|
70
|
+
* log sync`'s whole subject. The routine case — a working log that merely
|
|
71
|
+
* extends the committed one — is reconciled rather than refused (APRV-346).
|
|
44
72
|
* - `up-preflight-dirty-protected` — some OTHER path the upstream range changes
|
|
45
73
|
* is locally modified, so `git merge --ff-only` would refuse to overwrite it.
|
|
46
74
|
* Named separately because the repair is different: look at the edit and
|
|
@@ -83,8 +111,14 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameS
|
|
|
83
111
|
import { spawn, spawnSync } from "node:child_process";
|
|
84
112
|
import { basename, dirname, join, resolve as resolvePathSegments } from "node:path";
|
|
85
113
|
import { fileURLToPath } from "node:url";
|
|
114
|
+
import { checkAttestation, policyBytesHash } from "../core/attest.js";
|
|
115
|
+
import { compareChains } from "../core/log-reconcile.js";
|
|
116
|
+
import { POLICY_FILENAMES } from "../core/policy-load.js";
|
|
117
|
+
import { verifyWithRecords } from "../core/verify.js";
|
|
86
118
|
import { EXIT_IO, EXIT_OK } from "./exit-codes.js";
|
|
87
119
|
import { currentBranch, failureText, fetchBase, git, repoPath, repoRoot, showBlob, } from "./git-scope.js";
|
|
120
|
+
import { logSync } from "./log-sync.js";
|
|
121
|
+
import { DEFAULT_LOG_PATH } from "./paths.js";
|
|
88
122
|
import { runbook, style } from "./style.js";
|
|
89
123
|
// ---------------------------------------------------------------------------
|
|
90
124
|
// Build freshness (moved here from cli/doctor.ts, APRV-215)
|
|
@@ -289,6 +323,7 @@ const ZERO = {
|
|
|
289
323
|
log_touched: false,
|
|
290
324
|
dist_stale: false,
|
|
291
325
|
reexec: false,
|
|
326
|
+
log_synced: false,
|
|
292
327
|
};
|
|
293
328
|
/** `git status --porcelain -uno` as a set of repo-relative paths. */
|
|
294
329
|
function dirtyPaths(root) {
|
|
@@ -336,7 +371,68 @@ function counts(root, base) {
|
|
|
336
371
|
return { behind, ahead };
|
|
337
372
|
}
|
|
338
373
|
function skipped(detail, root) {
|
|
339
|
-
return {
|
|
374
|
+
return {
|
|
375
|
+
ok: true,
|
|
376
|
+
facts: { ...ZERO, action: "skipped" },
|
|
377
|
+
detail,
|
|
378
|
+
warning: null,
|
|
379
|
+
target: null,
|
|
380
|
+
root,
|
|
381
|
+
sync: null,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Is this protected-path collision the routine one, and what would clearing it
|
|
386
|
+
* keep? `null` means "not a question this module answers", and the caller
|
|
387
|
+
* refuses (APRV-346).
|
|
388
|
+
*
|
|
389
|
+
* Four conditions, and every one of them is a reason to refuse rather than a
|
|
390
|
+
* degree of confidence:
|
|
391
|
+
*
|
|
392
|
+
* 1. **The log is the repository's own.** `approval log sync` reconciles the log
|
|
393
|
+
* at `.approval/log/events.jsonl` in the checkout it runs in, so a preflight
|
|
394
|
+
* pointed at some other file with `--log` must not hand it a reconcile of a
|
|
395
|
+
* file it was never asked about. That is a mismatch a test fixture can have
|
|
396
|
+
* and the primary checkout cannot, which is exactly when a guard is cheap.
|
|
397
|
+
* 2. **Nothing else is in the merge's way.** `logSync` ends in `git merge
|
|
398
|
+
* --ff-only`, which refuses over any dirty tracked path. A dirty unrelated
|
|
399
|
+
* file is the `up-preflight-dirty-protected` conversation and is not made
|
|
400
|
+
* better by starting a ceremony that will stop half-way.
|
|
401
|
+
* 3. **Both chains can be read.** `compareChains` verifies each side before it
|
|
402
|
+
* compares a single seq, and a side that does not verify is a refusal rather
|
|
403
|
+
* than an answer (SPEC §11.1: enforcement paths read only verified records).
|
|
404
|
+
* 4. **They are in a prefix relationship.** `ahead`, `behind` and `equal` all
|
|
405
|
+
* mean the longer chain contains the other whole, so adopting it extends and
|
|
406
|
+
* rewinds nothing. `diverged` means two appenders built different records on
|
|
407
|
+
* one predecessor, hash chains do not merge, and no verb here will pick.
|
|
408
|
+
*
|
|
409
|
+
* Read-only throughout, so doctor can ask the same question without doctor ever
|
|
410
|
+
* having done anything.
|
|
411
|
+
*/
|
|
412
|
+
function planLogSync(root, logPath, target, upstream, dirty, protectedPaths) {
|
|
413
|
+
const logRelative = repoPath(root, logPath);
|
|
414
|
+
if (logRelative !== DEFAULT_LOG_PATH)
|
|
415
|
+
return null;
|
|
416
|
+
const others = [...upstream].filter((path) => dirty.has(path) && !protectedPaths.has(path));
|
|
417
|
+
if (others.length > 0)
|
|
418
|
+
return null;
|
|
419
|
+
let working;
|
|
420
|
+
try {
|
|
421
|
+
const bytes = readIfPresent(logPath);
|
|
422
|
+
working = bytes === null ? "" : bytes.toString("utf8");
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
const committed = showBlob(root, target, logRelative);
|
|
428
|
+
if (committed === null)
|
|
429
|
+
return null;
|
|
430
|
+
const compared = compareChains({ label: `the working log ${logPath}`, text: working }, { label: `the committed log at ${target.slice(0, 12)}`, text: committed.toString("utf8") });
|
|
431
|
+
if (!compared.ok)
|
|
432
|
+
return null;
|
|
433
|
+
if (compared.drift.relation === "diverged")
|
|
434
|
+
return null;
|
|
435
|
+
return { relation: compared.drift.relation, kept: compared.drift.ahead };
|
|
340
436
|
}
|
|
341
437
|
/**
|
|
342
438
|
* The whole judgment, and not one byte of action.
|
|
@@ -373,6 +469,7 @@ export function inspectPreflight(input) {
|
|
|
373
469
|
warning: fetched.message,
|
|
374
470
|
target: null,
|
|
375
471
|
root,
|
|
472
|
+
sync: null,
|
|
376
473
|
};
|
|
377
474
|
}
|
|
378
475
|
base = fetched.sha;
|
|
@@ -404,6 +501,7 @@ export function inspectPreflight(input) {
|
|
|
404
501
|
dist_stale: stale,
|
|
405
502
|
action,
|
|
406
503
|
reexec: false,
|
|
504
|
+
log_synced: false,
|
|
407
505
|
});
|
|
408
506
|
// 1. Ahead. Nothing else is worth judging: whatever the upstream range holds,
|
|
409
507
|
// a fast-forward is not the operation for a checkout carrying commits the
|
|
@@ -449,6 +547,7 @@ export function inspectPreflight(input) {
|
|
|
449
547
|
root,
|
|
450
548
|
target: base,
|
|
451
549
|
warning,
|
|
550
|
+
sync: null,
|
|
452
551
|
facts: facts(stale ? "rebuild" : "none"),
|
|
453
552
|
detail: stale
|
|
454
553
|
? `up to date with ${remote}/${branch}, and the build is older than the sources`
|
|
@@ -462,38 +561,29 @@ export function inspectPreflight(input) {
|
|
|
462
561
|
// does this — snapshot, baseline, fast-forward, reconcile, rebuild the
|
|
463
562
|
// projections — and this module deliberately does not reimplement it.
|
|
464
563
|
const collidingProtected = [...protectedPaths].filter((path) => upstream.has(path) && dirty.has(path));
|
|
465
|
-
|
|
564
|
+
// ...unless the collision is the routine one, which is the state the primary
|
|
565
|
+
// checkout is in after every records advance (APRV-346). `planLogSync` asks
|
|
566
|
+
// the chains themselves; a plan is a reconcile `runPreflight` will delegate to
|
|
567
|
+
// `approval log sync`, and `null` is the refusal below, unchanged.
|
|
568
|
+
const plan = collidingProtected.length === 0
|
|
569
|
+
? null
|
|
570
|
+
: planLogSync(root, input.logPath, base, upstream, dirty, protectedPaths);
|
|
571
|
+
if (collidingProtected.length > 0 && plan === null) {
|
|
466
572
|
return {
|
|
467
573
|
ok: false,
|
|
468
574
|
root,
|
|
469
575
|
facts: facts("refused"),
|
|
470
|
-
refusal:
|
|
471
|
-
code: "up-preflight-log-diverged",
|
|
472
|
-
headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
|
|
473
|
-
state: [
|
|
474
|
-
`${plural(counted.behind, "commit")} behind ${remote}/${branch}`,
|
|
475
|
-
`changed on both sides: ${collidingProtected.join(", ")}`,
|
|
476
|
-
"the working log was not read, moved, or rewound",
|
|
477
|
-
],
|
|
478
|
-
steps: [
|
|
479
|
-
{
|
|
480
|
-
command: "approval log sync",
|
|
481
|
-
note: "snapshots the working log, fast-forwards, reconciles the chain",
|
|
482
|
-
},
|
|
483
|
-
{ command: "approval up", note: "again, once sync reports clean" },
|
|
484
|
-
],
|
|
485
|
-
footer: [
|
|
486
|
-
"a fast-forward over a log another process is appending to is how one chain becomes two",
|
|
487
|
-
"the ritual and what it refuses: docs/cli-reference.md#log-sync",
|
|
488
|
-
],
|
|
489
|
-
next: "approval log sync",
|
|
490
|
-
},
|
|
576
|
+
refusal: divergedRefusal(remote, branch, counted.behind, collidingProtected),
|
|
491
577
|
};
|
|
492
578
|
}
|
|
493
579
|
// 3. Any other local modification in the fast-forward's way. `--ff-only` would
|
|
494
580
|
// refuse rather than clobber it, so the refusal is reported here, where it
|
|
495
|
-
// can say which file and what the two ways out are.
|
|
496
|
-
|
|
581
|
+
// can say which file and what the two ways out are. The protected pair is
|
|
582
|
+
// exempt when there is a plan: `log sync` is the writer that moves those
|
|
583
|
+
// two, and it has already been asked whether it can.
|
|
584
|
+
const colliding = [...upstream]
|
|
585
|
+
.filter((path) => dirty.has(path) && !(plan !== null && protectedPaths.has(path)))
|
|
586
|
+
.sort();
|
|
497
587
|
if (colliding.length > 0) {
|
|
498
588
|
return {
|
|
499
589
|
ok: false,
|
|
@@ -527,8 +617,46 @@ export function inspectPreflight(input) {
|
|
|
527
617
|
root,
|
|
528
618
|
target: base,
|
|
529
619
|
warning,
|
|
620
|
+
sync: plan,
|
|
530
621
|
facts: facts(stale ? "fast-forward+rebuild" : "fast-forward"),
|
|
531
|
-
detail: `${plural(counted.behind, "commit")} behind ${remote}/${branch}, and the upstream range is safe to fast-forward${stale ? "; the build is older than the sources" : ""}`,
|
|
622
|
+
detail: `${plural(counted.behind, "commit")} behind ${remote}/${branch}, and the upstream range is safe to fast-forward${plan === null ? "" : `, once \`approval log sync\`'s reconcile has kept the ${plural(plan.kept, "local record")}`}${stale ? "; the build is older than the sources" : ""}`,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The `up-preflight-log-diverged` refusal, in one place.
|
|
627
|
+
*
|
|
628
|
+
* Two callers now: the judgment that decides the chains cannot be reconciled
|
|
629
|
+
* without a human, and the one case where `logSync` — asked to reconcile a pair
|
|
630
|
+
* this module had already found reconcilable — refuses anyway. The second is a
|
|
631
|
+
* race (an appender that beat the lock, a fork that landed between the read and
|
|
632
|
+
* the ceremony) or a machine problem, and it says so in `extra` rather than in a
|
|
633
|
+
* code of its own: the repair is the same repair, and a code whose repair is
|
|
634
|
+
* another code's is not a distinct refusal, it is a synonym.
|
|
635
|
+
*/
|
|
636
|
+
function divergedRefusal(remote, branch, behind, collidingProtected, extra = []) {
|
|
637
|
+
return {
|
|
638
|
+
code: "up-preflight-log-diverged",
|
|
639
|
+
headline: `${remote}/${branch} changed ${collidingProtected.join(" and ")} and so did this working copy`,
|
|
640
|
+
state: [
|
|
641
|
+
`${plural(behind, "commit")} behind ${remote}/${branch}`,
|
|
642
|
+
`changed on both sides: ${collidingProtected.join(", ")}`,
|
|
643
|
+
...extra,
|
|
644
|
+
extra.length === 0
|
|
645
|
+
? "the working log was not read, moved, or rewound"
|
|
646
|
+
: "the reconcile restored the working log exactly as it found it, and nothing was started",
|
|
647
|
+
],
|
|
648
|
+
steps: [
|
|
649
|
+
{
|
|
650
|
+
command: "approval log sync",
|
|
651
|
+
note: "snapshots the working log, fast-forwards, reconciles the chain",
|
|
652
|
+
},
|
|
653
|
+
{ command: "approval up", note: "again, once sync reports clean" },
|
|
654
|
+
],
|
|
655
|
+
footer: [
|
|
656
|
+
"a fast-forward over a log another process is appending to is how one chain becomes two",
|
|
657
|
+
"the ritual and what it refuses: docs/cli-reference.md#log-sync",
|
|
658
|
+
],
|
|
659
|
+
next: "approval log sync",
|
|
532
660
|
};
|
|
533
661
|
}
|
|
534
662
|
function plural(count, noun) {
|
|
@@ -555,6 +683,38 @@ export function runPreflight(input, spawnBuild = npmBuild) {
|
|
|
555
683
|
// nothing to do. It rides out on the warning line so the aside directory is
|
|
556
684
|
// printed where the operator is already looking.
|
|
557
685
|
let cleared = null;
|
|
686
|
+
// The routine protected-path collision, reconciled rather than refused
|
|
687
|
+
// (APRV-346). This runs BEFORE the fast-forward below and does that
|
|
688
|
+
// fast-forward itself: `logSync` holds the append lock for its whole ceremony
|
|
689
|
+
// — snapshot, baseline, `git merge --ff-only`, reconcile, rebuild the
|
|
690
|
+
// projections, post-verify — and this module supplies a caller for it rather
|
|
691
|
+
// than a second copy of any of that. The merge below then finds the checkout
|
|
692
|
+
// already at the target and says so.
|
|
693
|
+
let synced = null;
|
|
694
|
+
if (report.sync !== null && report.root !== null) {
|
|
695
|
+
const remote = input.remote ?? "origin";
|
|
696
|
+
const branch = input.branch ?? currentBranch(report.root) ?? "main";
|
|
697
|
+
const result = logSync({ cwd: report.root, remote, branch });
|
|
698
|
+
if (!result.ok) {
|
|
699
|
+
// Asked for a reconcile this module had already found reconcilable, and
|
|
700
|
+
// refused: an appender that took the lock first, a fork that landed in
|
|
701
|
+
// between, or git itself. Either way nothing starts, and the sync's own
|
|
702
|
+
// code and sentence go into the refusal so the operator is not sent to
|
|
703
|
+
// read a second one.
|
|
704
|
+
return {
|
|
705
|
+
ok: false,
|
|
706
|
+
facts: { ...facts, action: "refused" },
|
|
707
|
+
refusal: divergedRefusal(remote, branch, facts.behind_by, [repoPath(report.root, input.logPath)], [`approval log sync refused at its ${result.step} step (${result.code}): ${result.message}`]),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
synced = {
|
|
711
|
+
commit: result.report.commitAfter,
|
|
712
|
+
kept: report.sync.kept,
|
|
713
|
+
relation: report.sync.relation,
|
|
714
|
+
remote,
|
|
715
|
+
branch,
|
|
716
|
+
};
|
|
717
|
+
}
|
|
558
718
|
if (facts.behind_by > 0 && report.root !== null && report.target !== null) {
|
|
559
719
|
const root = report.root;
|
|
560
720
|
const target = report.target;
|
|
@@ -643,10 +803,11 @@ export function runPreflight(input, spawnBuild = npmBuild) {
|
|
|
643
803
|
const plan = stale && build ? reexecPlan(input.root) : null;
|
|
644
804
|
return {
|
|
645
805
|
ok: true,
|
|
646
|
-
facts: { ...settled, reexec: plan !== null },
|
|
806
|
+
facts: { ...settled, reexec: plan !== null, log_synced: synced !== null },
|
|
647
807
|
detail: report.detail,
|
|
648
808
|
warning: [warning, cleared].filter((part) => part !== null).join("; ") || null,
|
|
649
809
|
...(plan === null ? {} : { reexec: plan }),
|
|
810
|
+
...(synced === null ? {} : { synced }),
|
|
650
811
|
};
|
|
651
812
|
}
|
|
652
813
|
// ---------------------------------------------------------------------------
|
|
@@ -970,6 +1131,18 @@ export function describePreflightEvent(event) {
|
|
|
970
1131
|
if (event.event === "preflight_warning") {
|
|
971
1132
|
return { text: `approval: preflight — ${event.message}`, stderr: true };
|
|
972
1133
|
}
|
|
1134
|
+
if (event.event === "preflight_policy") {
|
|
1135
|
+
return {
|
|
1136
|
+
text: `up: preflight — ${event.detail}${event.fix === null ? "" : `; ${event.fix}`}`,
|
|
1137
|
+
stderr: true,
|
|
1138
|
+
};
|
|
1139
|
+
}
|
|
1140
|
+
if (event.event === "preflight_sync") {
|
|
1141
|
+
return {
|
|
1142
|
+
text: `up: preflight — synced: fast-forwarded to ${event.remote}/${event.branch} ${event.commit.slice(0, 12)}, kept ${String(event.kept)} local records`,
|
|
1143
|
+
stderr: false,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
973
1146
|
const commits = `${String(event.behind_by)} commit${event.behind_by === 1 ? "" : "s"}`;
|
|
974
1147
|
const did = {
|
|
975
1148
|
none: "already at the remote tip, on a build no older than the sources",
|
|
@@ -989,6 +1162,27 @@ export function describePreflightEvent(event) {
|
|
|
989
1162
|
const handover = event.reexec ? ", in a fresh process on the new build" : "";
|
|
990
1163
|
return { text: `up: preflight — ${did[event.action]}${running}${handover}`, stderr: false };
|
|
991
1164
|
}
|
|
1165
|
+
/**
|
|
1166
|
+
* The policy file `--policy` names, or the one `--dir` (else `cwd`) holds.
|
|
1167
|
+
*
|
|
1168
|
+
* Discovery, not a load: this answers WHICH FILE, so the preflight can compare
|
|
1169
|
+
* its attested hash against the remote's copy (APRV-342) before either caller
|
|
1170
|
+
* has loaded a policy. The order is `core/policy-load.ts`'s own, so the file
|
|
1171
|
+
* named here is the file the runtime will go on to enforce. `null` when there is
|
|
1172
|
+
* no such file, which is not a finding — `approval doctor`'s `policy` rows are
|
|
1173
|
+
* where an absent policy is somebody's problem.
|
|
1174
|
+
*/
|
|
1175
|
+
export function preflightPolicyPath(policyFlag, dirFlag, cwd) {
|
|
1176
|
+
if (policyFlag !== null)
|
|
1177
|
+
return resolvePathSegments(cwd, policyFlag);
|
|
1178
|
+
const dir = dirFlag === null ? cwd : resolvePathSegments(cwd, dirFlag);
|
|
1179
|
+
for (const filename of POLICY_FILENAMES) {
|
|
1180
|
+
const candidate = join(dir, filename);
|
|
1181
|
+
if (existsSync(candidate))
|
|
1182
|
+
return candidate;
|
|
1183
|
+
}
|
|
1184
|
+
return null;
|
|
1185
|
+
}
|
|
992
1186
|
/**
|
|
993
1187
|
* Run the preflight, print what it did, and answer whether the caller may start.
|
|
994
1188
|
*
|
|
@@ -1020,6 +1214,11 @@ export function startupPreflight(input) {
|
|
|
1020
1214
|
if (outcome.warning !== null) {
|
|
1021
1215
|
input.emit({ event: "preflight_warning", message: outcome.warning });
|
|
1022
1216
|
}
|
|
1217
|
+
// Before the `preflight` line, because it happened before what that line
|
|
1218
|
+
// reports: the reconcile is what made the fast-forward possible.
|
|
1219
|
+
if (outcome.synced !== undefined) {
|
|
1220
|
+
input.emit({ event: "preflight_sync", ...outcome.synced });
|
|
1221
|
+
}
|
|
1023
1222
|
// Emitted BEFORE the re-exec, and by the parent, because this is the only
|
|
1024
1223
|
// place the whole story is known: the child runs with `--no-preflight` and
|
|
1025
1224
|
// has nothing to say about a fast-forward it did not perform. The commit is
|
|
@@ -1030,8 +1229,75 @@ export function startupPreflight(input) {
|
|
|
1030
1229
|
detail: outcome.detail,
|
|
1031
1230
|
...outcome.facts,
|
|
1032
1231
|
});
|
|
1232
|
+
// APRV-342, and AFTER the fast-forward: a merge that just landed the
|
|
1233
|
+
// amendment is exactly the case where the answer changes, and reporting the
|
|
1234
|
+
// pre-merge one would name an interregnum this process had already left.
|
|
1235
|
+
const interregnum = attestedPolicyLine(input);
|
|
1236
|
+
if (interregnum !== null)
|
|
1237
|
+
input.emit(interregnum);
|
|
1033
1238
|
return { ok: true, reexec: outcome.reexec ?? null };
|
|
1034
1239
|
}
|
|
1240
|
+
/**
|
|
1241
|
+
* The `attested-policy-on-main` line, or `null` when there is nothing to say.
|
|
1242
|
+
*
|
|
1243
|
+
* `null` for a pass, a skip, and for every state the check cannot read: the
|
|
1244
|
+
* preflight's lines report what happened and what an operator has to act on,
|
|
1245
|
+
* and "your policy is where it should be" is neither. It NEVER refuses — see
|
|
1246
|
+
* {@link checkAttestedPolicyOnMain} for why a pending amendment is a normal
|
|
1247
|
+
* state of a repository rather than a fault.
|
|
1248
|
+
*
|
|
1249
|
+
* The log is re-verified here rather than passed in, because the preflight runs
|
|
1250
|
+
* before either caller has opened it. That is one whole-log read at startup, in
|
|
1251
|
+
* a process that is about to read the log on every tick.
|
|
1252
|
+
*/
|
|
1253
|
+
function attestedPolicyLine(input) {
|
|
1254
|
+
const policyPath = input.policyPath ?? null;
|
|
1255
|
+
if (policyPath === null)
|
|
1256
|
+
return null;
|
|
1257
|
+
const root = repoRoot(dirname(input.logPath));
|
|
1258
|
+
if (root === null)
|
|
1259
|
+
return null;
|
|
1260
|
+
// The cheap half first, and it answers the common case without opening the
|
|
1261
|
+
// log at all: when the remote's copy of the policy is byte-identical to the
|
|
1262
|
+
// one on disk, "is the attested policy on the remote" has the same answer as
|
|
1263
|
+
// "is the policy on disk attested" — which is doctor's `attestation` row, and
|
|
1264
|
+
// which `up` has no business duplicating on its startup line. The expensive
|
|
1265
|
+
// half below is a whole-log verify, so skipping it whenever the answer is
|
|
1266
|
+
// already settled is the difference between a read per start and a read per
|
|
1267
|
+
// start on a repository mid-amendment.
|
|
1268
|
+
const relative = repoPath(root, policyPath);
|
|
1269
|
+
if (relative.startsWith(".."))
|
|
1270
|
+
return null;
|
|
1271
|
+
const remote = input.remote ?? "origin";
|
|
1272
|
+
const branch = input.branch ?? currentBranch(root) ?? "main";
|
|
1273
|
+
const resolved = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${branch}^{commit}`], root);
|
|
1274
|
+
const tip = resolved.stdout.trim();
|
|
1275
|
+
if (!resolved.ok || tip.length === 0)
|
|
1276
|
+
return null;
|
|
1277
|
+
const blob = showBlob(root, tip, relative);
|
|
1278
|
+
let onDisk;
|
|
1279
|
+
try {
|
|
1280
|
+
onDisk = readIfPresent(policyPath);
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return null;
|
|
1284
|
+
}
|
|
1285
|
+
if (blob !== null && onDisk !== null && blob.equals(onDisk))
|
|
1286
|
+
return null;
|
|
1287
|
+
const verified = verifyWithRecords(input.logPath);
|
|
1288
|
+
if (verified.result.status !== "clean")
|
|
1289
|
+
return null;
|
|
1290
|
+
const row = checkAttestedPolicyOnMain({
|
|
1291
|
+
policyPath,
|
|
1292
|
+
records: verified.records,
|
|
1293
|
+
root,
|
|
1294
|
+
...(input.remote === null ? {} : { remote: input.remote }),
|
|
1295
|
+
...(input.branch === null ? {} : { branch: input.branch }),
|
|
1296
|
+
});
|
|
1297
|
+
if (row.status !== "fail")
|
|
1298
|
+
return null;
|
|
1299
|
+
return { event: "preflight_policy", detail: row.detail, fix: row.fix ?? null };
|
|
1300
|
+
}
|
|
1035
1301
|
/** The short sha this checkout is on, or `null` when git will not say. */
|
|
1036
1302
|
function headCommit(logPath) {
|
|
1037
1303
|
const root = repoRoot(dirname(logPath));
|
|
@@ -1146,6 +1412,90 @@ function npmBuild(root) {
|
|
|
1146
1412
|
* never `git`. That constraint predates this row and is the right one — a repair
|
|
1147
1413
|
* line telling an operator to reset a branch would be doctor making a decision.
|
|
1148
1414
|
*/
|
|
1415
|
+
/**
|
|
1416
|
+
* `attested-policy-on-main`, doctor's row for the interregnum (APRV-342).
|
|
1417
|
+
*
|
|
1418
|
+
* Between a policy amendment and its pull request merging there is a window
|
|
1419
|
+
* where the attestation is in the log and the amended `APPROVAL.md` is in the
|
|
1420
|
+
* working tree, and `origin/main` carries neither. A fresh checkout of main in
|
|
1421
|
+
* that window has the OLD policy with no attestation covering it, and every
|
|
1422
|
+
* gate operation there refuses `policy-not-attested`.
|
|
1423
|
+
*
|
|
1424
|
+
* Nothing said so. On 2026-09-16 `approval up` ran its preflight in exactly that
|
|
1425
|
+
* state and reported "already at the remote tip"; doctor's `attestation` row
|
|
1426
|
+
* passed, because the LOCAL file is attested and that row asks a different
|
|
1427
|
+
* question. This row asks the missing one: is the policy the log vouches for the
|
|
1428
|
+
* policy `origin/<branch>` carries?
|
|
1429
|
+
*
|
|
1430
|
+
* Read-only, and networkless. The remote tip is read from the last fetch, like
|
|
1431
|
+
* every other answer doctor gives about a remote, and the `policy-amend-<seq>`
|
|
1432
|
+
* branch is looked for among the remote-tracking refs rather than asked of
|
|
1433
|
+
* GitHub: a report that reached the network to be more accurate would be doing
|
|
1434
|
+
* something on its own account.
|
|
1435
|
+
*/
|
|
1436
|
+
export function checkAttestedPolicyOnMain(input) {
|
|
1437
|
+
const check = "attested-policy-on-main";
|
|
1438
|
+
const root = input.root;
|
|
1439
|
+
if (root === null) {
|
|
1440
|
+
return {
|
|
1441
|
+
check,
|
|
1442
|
+
status: "skip",
|
|
1443
|
+
detail: `${input.policyPath} is not inside a git repository, so there is no remote copy of it to compare the attestation against`,
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
const status = checkAttestation([...input.records], input.policyPath);
|
|
1447
|
+
// Not applicable rather than a pass: with no attestation there is no hash to
|
|
1448
|
+
// compare, and `attestation` is the row that has something to say about that.
|
|
1449
|
+
const attested = status.status === "attested"
|
|
1450
|
+
? { sha256: status.sha256, seq: status.seq }
|
|
1451
|
+
: status.status === "hash-mismatch"
|
|
1452
|
+
? { sha256: status.attestedSha256, seq: status.seq }
|
|
1453
|
+
: null;
|
|
1454
|
+
if (attested === null) {
|
|
1455
|
+
return {
|
|
1456
|
+
check,
|
|
1457
|
+
status: "skip",
|
|
1458
|
+
detail: `${input.policyPath} carries no attestation, so there is no attested hash to look for on the remote`,
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
const remote = input.remote ?? "origin";
|
|
1462
|
+
const branch = input.branch ?? currentBranch(root) ?? "main";
|
|
1463
|
+
const ref = `refs/remotes/${remote}/${branch}`;
|
|
1464
|
+
const resolved = git(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], root);
|
|
1465
|
+
const tip = resolved.stdout.trim();
|
|
1466
|
+
if (!resolved.ok || tip.length === 0) {
|
|
1467
|
+
return {
|
|
1468
|
+
check,
|
|
1469
|
+
status: "skip",
|
|
1470
|
+
detail: `this checkout has no ${remote}/${branch} remote-tracking ref, so there is no remote copy of ${input.policyPath} to compare the attestation against`,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
const relative = repoPath(root, input.policyPath);
|
|
1474
|
+
const blob = relative.startsWith("..") ? null : showBlob(root, tip, relative);
|
|
1475
|
+
const remoteSha256 = blob === null ? null : policyBytesHash(blob);
|
|
1476
|
+
if (remoteSha256 === attested.sha256) {
|
|
1477
|
+
return {
|
|
1478
|
+
check,
|
|
1479
|
+
status: "pass",
|
|
1480
|
+
detail: `${remote}/${branch} carries the policy attested at seq ${String(attested.seq)} (sha256 ${attested.sha256.slice(0, 12)}…)`,
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
// The branch the amendment would ride, when this checkout has already seen it
|
|
1484
|
+
// on the remote. Named in the detail rather than in the fix: `approval policy
|
|
1485
|
+
// amend --pr` is the command either way, because it updates an open pull
|
|
1486
|
+
// request rather than opening a second one (APRV-341).
|
|
1487
|
+
const amendBranch = `policy-amend-${String(attested.seq)}`;
|
|
1488
|
+
const pushed = git(["rev-parse", "--verify", "--quiet", `refs/remotes/${remote}/${amendBranch}^{commit}`], root);
|
|
1489
|
+
const carried = pushed.ok && pushed.stdout.trim().length > 0;
|
|
1490
|
+
return {
|
|
1491
|
+
check,
|
|
1492
|
+
status: "fail",
|
|
1493
|
+
detail: `attested at seq ${String(attested.seq)}, not yet on main: ${remote}/${branch} carries ${remoteSha256 === null ? `no ${relative} at all` : `${relative} hashing ${remoteSha256.slice(0, 12)}…`} while the attestation covers ${attested.sha256.slice(0, 12)}…${carried
|
|
1494
|
+
? `; ${remote} already carries ${amendBranch}, so its pull request is what lands it`
|
|
1495
|
+
: ""}. A fresh checkout of ${branch} refuses every gate operation with policy-not-attested until it merges`,
|
|
1496
|
+
fix: `approval policy amend --pr — commits the policy and its attestation on ${amendBranch}, opens or updates its pull request, and arms the merge`,
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1149
1499
|
export function checkMainBehindOrigin(logPath, queuePath, root) {
|
|
1150
1500
|
const report = inspectPreflight({ logPath, queuePath, root, fetch: false });
|
|
1151
1501
|
if (!report.ok) {
|
|
@@ -1165,10 +1515,17 @@ export function checkMainBehindOrigin(logPath, queuePath, root) {
|
|
|
1165
1515
|
if (report.facts.behind_by === 0 && !report.facts.dist_stale) {
|
|
1166
1516
|
return { check: "main-behind-origin", status: "pass", detail: `${report.detail}${suffix}` };
|
|
1167
1517
|
}
|
|
1518
|
+
// A plan is worth naming even though it is a pass: the row is where an
|
|
1519
|
+
// operator looks to find out whether starting will be one command or two, and
|
|
1520
|
+
// "your working log extends the committed one" is the answer that used to
|
|
1521
|
+
// arrive as a refusal (APRV-346).
|
|
1522
|
+
const plan = report.sync === null
|
|
1523
|
+
? ""
|
|
1524
|
+
: `; the working log is a clean extension (${report.sync.relation}, ${plural(report.sync.kept, "local record")}), so up reconciles it rather than refusing`;
|
|
1168
1525
|
return {
|
|
1169
1526
|
check: "main-behind-origin",
|
|
1170
1527
|
status: "pass",
|
|
1171
|
-
detail: `${report.detail}; upstream ${report.facts.log_touched ? "DOES" : "does not"} touch the working log or queue${suffix}`,
|
|
1528
|
+
detail: `${report.detail}; upstream ${report.facts.log_touched ? "DOES" : "does not"} touch the working log or queue${plan}${suffix}`,
|
|
1172
1529
|
fix: "approval up — fast-forwards and rebuilds when it is safe, and refuses with the next command when it is not",
|
|
1173
1530
|
};
|
|
1174
1531
|
}
|