@pareto4/pp-core 1.13.0 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +3 -3
- package/README.md +26 -9
- package/agents/pp-code-reviewer.md +2 -1
- package/agents/pp-debug-session-manager.md +9 -6
- package/agents/pp-executor.md +10 -13
- package/agents/pp-plan-checker.md +4 -4
- package/agents/pp-planner.md +23 -9
- package/agents/pp-vault-reviewer.md +244 -21
- package/agents/pp-verifier.md +7 -3
- package/bin/install.js +10 -3
- package/commands/pp-execute-phase.md +1 -3
- package/commands/pp-ns-review.md +1 -1
- package/commands/pp-plan-phase.md +1 -2
- package/commands/pp-vault-review.md +5 -3
- package/commands/pp-verify-work.md +1 -2
- package/gemini-extension.json +1 -1
- package/hooks/dist/pp-check-update.js +0 -0
- package/hooks/dist/pp-cursor-session-start.js +1 -1
- package/hooks/dist/pp-statusline.js +1 -1
- package/hooks/dist/pp-update-banner.js +0 -0
- package/hooks/pp-check-update.js +0 -0
- package/hooks/pp-cursor-session-start.js +1 -1
- package/hooks/pp-statusline.js +1 -1
- package/hooks/pp-update-banner.js +0 -0
- package/package.json +14 -19
- package/pareto-skills/_shared/create-project-board.sh +0 -0
- package/pareto-skills/pp-project-setup/SKILL.md +1 -1
- package/pp-core/bin/check-latest-version.cjs +0 -0
- package/pp-core/bin/detect-build-commands.sh +2 -2
- package/pp-core/bin/lib/capability-activation.cjs +83 -9
- package/pp-core/bin/lib/capability-registry.cjs +396 -82
- package/pp-core/bin/lib/capability-state.cjs +160 -4
- package/pp-core/bin/lib/capability-writer.cjs +19 -5
- package/pp-core/bin/lib/check-command-router.cjs +3334 -946
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/command-aliases.cjs +0 -35
- package/pp-core/bin/lib/commands.cjs +9 -4
- package/pp-core/bin/lib/compose-bindings.cjs +251 -88
- package/pp-core/bin/lib/config-loader.cjs +7 -103
- package/pp-core/bin/lib/config-schema.cjs +1 -1
- package/pp-core/bin/lib/config.cjs +154 -62
- package/pp-core/bin/lib/decisions.cjs +4 -4
- package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
- package/pp-core/bin/lib/frontmatter.cjs +1 -38
- package/pp-core/bin/lib/git-base-branch.cjs +13 -11
- package/pp-core/bin/lib/git-provider.cjs +4 -4
- package/pp-core/bin/lib/init-command-router.cjs +5 -8
- package/pp-core/bin/lib/init-contract.cjs +648 -738
- package/pp-core/bin/lib/init-interactive.cjs +76 -4
- package/pp-core/bin/lib/init.cjs +24 -51
- package/pp-core/bin/lib/install-profiles.cjs +1 -1
- package/pp-core/bin/lib/io.cjs +8 -0
- package/pp-core/bin/lib/loop-resolver.cjs +189 -129
- package/pp-core/bin/lib/org-profile.cjs +469 -152
- package/pp-core/bin/lib/package-identity.cjs +3 -3
- package/pp-core/bin/lib/phase-command-router.cjs +1 -29
- package/pp-core/bin/lib/phase-scope.cjs +1 -1
- package/pp-core/bin/lib/phase.cjs +11 -253
- package/pp-core/bin/lib/platform-bindings.cjs +40 -19
- package/pp-core/bin/lib/practices-review-check.cjs +15 -9
- package/pp-core/bin/lib/profile-output.cjs +1 -1
- package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
- package/pp-core/bin/lib/state.cjs +11 -85
- package/pp-core/bin/lib/template.cjs +1 -1
- package/pp-core/bin/lib/vault-audit.cjs +650 -126
- package/pp-core/bin/lib/vault-command-router.cjs +2604 -367
- package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
- package/pp-core/bin/lib/vault-index.cjs +699 -0
- package/pp-core/bin/lib/vault-mode.cjs +235 -0
- package/pp-core/bin/lib/vault-practices.cjs +139 -72
- package/pp-core/bin/lib/vault-review.cjs +194 -451
- package/pp-core/bin/lib/vault-seal.cjs +1936 -0
- package/pp-core/bin/lib/vault-standards.cjs +1505 -0
- package/pp-core/bin/lib/vault-trace.cjs +607 -254
- package/pp-core/bin/lib/vault-update.cjs +9 -33
- package/pp-core/bin/lib/vault-write-back.cjs +70 -42
- package/pp-core/bin/lib/vault.cjs +2734 -920
- package/pp-core/bin/lib/verify.cjs +0 -11
- package/pp-core/bin/portable-timeout.sh +1 -1
- package/pp-core/bin/pp-tools.cjs +184 -54
- package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
- package/pp-core/bin/shared/config-schema.manifest.json +0 -3
- package/pp-core/bin/shared/model-catalog.json +2 -2
- package/pp-core/bin/verify-reapply-patches.cjs +0 -0
- package/pp-core/org-profile.json +26 -0
- package/pp-core/references/autonomous-best-effort.md +8 -7
- package/pp-core/references/loop-hook-dispatch.md +0 -25
- package/pp-core/references/planner-gap-closure.md +0 -1
- package/pp-core/references/planner-tdd.md +0 -1
- package/pp-core/references/planner-vault-practices.md +17 -34
- package/pp-core/references/planner-vault-standards.md +320 -0
- package/pp-core/references/planning-config.md +0 -8
- package/pp-core/references/practice-arithmetic-contract.md +121 -0
- package/pp-core/references/tdd.md +0 -1
- package/pp-core/references/verifier-practice-verdicts.md +263 -0
- package/pp-core/templates/README.md +1 -0
- package/pp-core/templates/VAULT-REVIEW.md +202 -0
- package/pp-core/templates/phase-prompt.md +2 -6
- package/pp-core/templates/practices/README.md +20 -23
- package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
- package/pp-core/workflows/add-tests.md +55 -4
- package/pp-core/workflows/audit-fix.md +63 -2
- package/pp-core/workflows/audit-milestone.md +1 -1
- package/pp-core/workflows/autonomous.md +85 -11
- package/pp-core/workflows/check-todos.md +13 -2
- package/pp-core/workflows/code-review-fix.md +59 -3
- package/pp-core/workflows/code-review.md +10 -1
- package/pp-core/workflows/complete-milestone.md +1 -1
- package/pp-core/workflows/debug.md +54 -4
- package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
- package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
- package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
- package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
- package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
- package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
- package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
- package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
- package/pp-core/workflows/discovery-sprint.md +7 -3
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
- package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
- package/pp-core/workflows/discovery-to-build.md +10 -2
- package/pp-core/workflows/discuss-phase.md +3 -10
- package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
- package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
- package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
- package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
- package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
- package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -20
- package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
- package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
- package/pp-core/workflows/execute-phase.md +155 -128
- package/pp-core/workflows/execute-plan.md +56 -6
- package/pp-core/workflows/fast.md +53 -4
- package/pp-core/workflows/help/modes/full.md +3 -3
- package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
- package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
- package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
- package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
- package/pp-core/workflows/init.md +232 -272
- package/pp-core/workflows/new-project.md +31 -23
- package/pp-core/workflows/plan-phase.md +98 -45
- package/pp-core/workflows/quick.md +117 -3
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/ship.md +57 -28
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/update.md +3 -3
- package/pp-core/workflows/validate-phase.md +58 -3
- package/pp-core/workflows/vault-review.md +282 -22
- package/pp-core/workflows/verify-work.md +12 -14
- package/scripts/changeset/cli.cjs +6 -5
- package/scripts/changeset/lint.cjs +0 -0
- package/scripts/changeset/new.cjs +33 -50
- package/scripts/dev-env.cjs +25 -85
- package/scripts/gen-capability-registry.cjs +199 -11
- package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
- package/scripts/lib/allowlist-ratchet.cjs +1 -103
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
- package/scripts/lint-docs-required.cjs +0 -0
- package/scripts/lint-pr-check-project-dir.cjs +1 -3
- package/scripts/lint-test-file-count.allowlist.json +12 -10
- package/scripts/practice-arithmetic-contract.allow.json +35 -0
- package/scripts/prompt-injection-scan.sh +22 -17
- package/scripts/record-vault-mode-golden.cjs +89 -0
- package/scripts/release-channel.cjs +0 -0
- package/scripts/sync-manifest-versions.cjs +5 -4
- package/scripts/sync-rulesets.sh +1 -1
- package/scripts/update-size-baseline.cjs +30 -274
- package/scripts/vault-calibrate.cjs +996 -0
- package/scripts/verify-opengsd-sweep.cjs +3 -5
- package/scripts/workflow-size.cjs +9 -61
- package/pp-core/bin/lib/dispatch-plan.cjs +0 -483
- package/pp-core/bin/lib/install-run.cjs +0 -536
- package/pp-core/bin/lib/loop-gates.cjs +0 -213
- package/pp-core/bin/lib/phase-manifest.cjs +0 -527
- package/pp-core/bin/lib/phase-tier.cjs +0 -480
- package/pp-core/bin/lib/plan-graph.cjs +0 -368
- package/pp-core/references/executor-vault-trace.md +0 -47
- package/pp-core/references/planner-plan-graph.md +0 -66
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
- package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
- package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
- package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -73
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
- package/scripts/lib/size-budget.cjs +0 -311
- package/scripts/lint-neutrality-names.cjs +0 -694
- package/scripts/lint-org-coordinates.allowlist.json +0 -35
- package/scripts/lint-org-coordinates.cjs +0 -426
|
@@ -17,7 +17,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
17
17
|
*
|
|
18
18
|
* Arg indexing:
|
|
19
19
|
* args[0] = 'vault' (family — matched by dispatchCapabilityCommand)
|
|
20
|
-
* args[1] = subcommand (
|
|
20
|
+
* args[1] = subcommand (see VAULT_SUBCOMMANDS below)
|
|
21
21
|
*
|
|
22
22
|
* Test seams: pass `_vault` to inject a mock vault module; pass `_core` to inject
|
|
23
23
|
* a mock core module that captures `output()` calls instead of writing to real
|
|
@@ -34,76 +34,186 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
34
34
|
const io = require("./io.cjs");
|
|
35
35
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
36
36
|
const node_path_1 = __importDefault(require("node:path"));
|
|
37
|
+
// 16-19 (D-107): `vault anchor certify` runs the strict trace check as a child
|
|
38
|
+
// process, the one spawn in this router (TRACE-04 keeps the check read-only).
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
const childProcess = require("node:child_process");
|
|
37
41
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
38
|
-
//
|
|
39
|
-
//
|
|
42
|
+
// CR-04 (03-11): the trace render arm resolves its containment boundary the same
|
|
43
|
+
// way `cmdVaultTrace` in src/check-command-router.cts does. project-root.cjs is a
|
|
44
|
+
// leaf (node builtins only), so this adds no cycle.
|
|
40
45
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
46
|
const projectRootMod = require("./project-root.cjs");
|
|
42
47
|
const { findProjectRoot } = projectRootMod;
|
|
48
|
+
// 15-05: the plan-identifier form moved into the vault-standards leaf (one
|
|
49
|
+
// definition shared by the trace verbs, the standards verbs and the gate).
|
|
50
|
+
// That leaf requires only vault-index, which imports nothing, so no cycle.
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
52
|
+
const vaultStandardsLeaf = require("./vault-standards.cjs");
|
|
53
|
+
// 16-06 (D-14/D-31/D-35): the anchor grammar, the anchors file and the door
|
|
54
|
+
// record have ONE owner, the seal leaf. Its top level imports only `fs` and
|
|
55
|
+
// `path` (D-41), so this static import adds no cycle.
|
|
56
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
57
|
+
const vaultSeal = require("./vault-seal.cjs");
|
|
43
58
|
const { ERROR_REASON } = io;
|
|
44
|
-
// Default CoreModule implementation. _core seam overrides this for test injection.
|
|
45
|
-
const _defaultCore = { output: io.output };
|
|
46
59
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
* The subcommand vocabulary, declared once. 02-02 added `probe` (STRICT-02) and
|
|
61
|
+
* found the name had to be repeated in three unrelated places — the header
|
|
62
|
+
* comment, the dispatch chain and the unknown-subcommand message — with nothing
|
|
63
|
+
* keeping them in step. The message is now DERIVED from this list so the
|
|
64
|
+
* dispatch arm and the operator-facing vocabulary can no longer disagree.
|
|
65
|
+
*/
|
|
66
|
+
const VAULT_SUBCOMMANDS = [
|
|
67
|
+
'fetch',
|
|
68
|
+
'status',
|
|
69
|
+
'probe',
|
|
70
|
+
'summary',
|
|
71
|
+
'practices',
|
|
72
|
+
'org-profile',
|
|
73
|
+
'trace',
|
|
74
|
+
// 05-06 (CHECK-03): the mechanical practice audit. Added HERE and nowhere
|
|
75
|
+
// else — the header comment above, the dispatch chain and the
|
|
76
|
+
// unknown-subcommand message all derive from this list, which is exactly the
|
|
77
|
+
// three-place drift 02-02 removed.
|
|
78
|
+
'audit',
|
|
79
|
+
// 260915-ik2 (UAT-6-F1): the phase-manifest writer the execute-phase step
|
|
80
|
+
// files call — `start` at execute:pre, `record` at verify:post. Added HERE
|
|
81
|
+
// and nowhere else, for the same three-place reason as `audit` above.
|
|
82
|
+
'manifest',
|
|
83
|
+
// PBI 74316 (fork-only): move the project to another client vault version.
|
|
84
|
+
'update',
|
|
85
|
+
// 15-07 (D-06, D-13): the standards record verbs, `block` and `consulted`.
|
|
86
|
+
'standards',
|
|
87
|
+
// 16-04 (D-28): the one door-admission verb every door and loop-point fence asks.
|
|
88
|
+
'admission',
|
|
89
|
+
// 16-06 (D-31, D-32): the start anchor a phase or quick directory records at
|
|
90
|
+
// execute:pre, and the attributed back-fill of a record written before anchors.
|
|
91
|
+
'anchor',
|
|
92
|
+
// 16-06 (D-12): the one verb that writes a door's mode-stamped record.
|
|
93
|
+
'door-record',
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* The two trace stages, declared once for the same reason the subcommand
|
|
97
|
+
* vocabulary is: the `--stage` validation and the operator-facing message that
|
|
98
|
+
* names the permitted values are both DERIVED from this list, so they cannot
|
|
99
|
+
* disagree. The values themselves are the frozen contract's
|
|
100
|
+
* (`docs/vault-trace-format.md` § The freeze contract) — this list restates the
|
|
101
|
+
* two stage NAMES the CLI accepts, and nothing else about the schema.
|
|
69
102
|
*/
|
|
70
103
|
const TRACE_STAGES = ['plan', 'execute'];
|
|
104
|
+
/**
|
|
105
|
+
* The trace verbs, same discipline (Phase 03-05).
|
|
106
|
+
*/
|
|
71
107
|
const TRACE_VERBS = ['scaffold', 'render'];
|
|
72
108
|
/**
|
|
73
|
-
* The
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
|
|
81
|
-
|
|
109
|
+
* The manifest verbs, same discipline (260915-ik2): the verb check and the
|
|
110
|
+
* operator-facing message that names the permitted verbs both derive from it.
|
|
111
|
+
*/
|
|
112
|
+
const MANIFEST_VERBS = ['start', 'record'];
|
|
113
|
+
/**
|
|
114
|
+
* The standards record verbs (15-07), same discipline: the verb check and the
|
|
115
|
+
* message naming the permitted verbs both derive from this list.
|
|
116
|
+
*/
|
|
117
|
+
const STANDARDS_VERBS = ['block', 'consulted'];
|
|
118
|
+
/**
|
|
119
|
+
* The anchor verbs (16-06), same discipline: the verb check and the message
|
|
120
|
+
* naming the permitted verbs both derive from this list.
|
|
121
|
+
*/
|
|
122
|
+
const ANCHOR_VERBS = ['start', 'backfill', 'certify'];
|
|
123
|
+
/** The flags `vault door-record` takes, declared once for the check and the message (16-06). */
|
|
124
|
+
// 16 review iteration 3 CR-02 (D-103): no `--start`. The start comes only from
|
|
125
|
+
// the door's own `--begin` entry, so no argument can widen a door's range.
|
|
126
|
+
const DOOR_RECORD_FLAGS = ['--door', '--artifact', '--phase'];
|
|
127
|
+
/**
|
|
128
|
+
* The read cap `vault standards consulted` applies to a PLAN. EQUAL to the
|
|
129
|
+
* trace gate's `TRACE_ARTIFACT_BYTE_CAP` in src/check-command-router.cts, so
|
|
130
|
+
* the verb never refuses a plan the gate would read, nor reads one the gate
|
|
131
|
+
* refuses. That constant is not importable from here (an adapter never
|
|
132
|
+
* imports an adapter), so the integration test pins the two equal.
|
|
133
|
+
*/
|
|
134
|
+
const STANDARDS_PLAN_READ_BYTES = 1024 * 1024;
|
|
135
|
+
/**
|
|
136
|
+
* The plan-identifier shape `vault trace scaffold --plan` accepts (04-14,
|
|
137
|
+
* TGATE-01, CR-03). Defined in `src/vault-standards.cts` since 15-05 and
|
|
138
|
+
* re-exported from here under the same name; the rationale stays here, beside
|
|
139
|
+
* the verb that first needed it.
|
|
140
|
+
*
|
|
141
|
+
* ONE FORM, READ AND WRITTEN. `filenamePlanId` in `src/check-command-router.cts`
|
|
142
|
+
* extracts `<this form>-PLAN.md` / `-SUMMARY.md` from an artifact's basename,
|
|
143
|
+
* building its pattern from this very value, and blocks when the identifier it
|
|
144
|
+
* extracts disagrees with the one the record carries. So the writer can emit
|
|
145
|
+
* every identifier the reader can demand (the trap CR-03 reproduced, where
|
|
146
|
+
* `--plan 01` for `07-01-PLAN.md` blocked at all four points and the remedy
|
|
147
|
+
* re-ran the same command), and the reader checks every form the writer emits.
|
|
148
|
+
* Until 15-REVIEW WR-06 the reader stopped at digits-hyphen-digits, so a
|
|
149
|
+
* decimal (`02.3-01-PLAN.md`, bug #3691) or lettered phase stated no
|
|
150
|
+
* identifier and its plan-id check was silently skipped; the writer was
|
|
151
|
+
* deliberately wider then, so as not to refuse a form the gate accepted
|
|
152
|
+
* (threat T-4-53). The shape is the plan-identifier grammar
|
|
153
|
+
* `agents/pp-planner.md` and `src/phase-id.cts` use: a phase segment (digits,
|
|
154
|
+
* an optional letter, optional decimal parts) followed by one or more numeric
|
|
155
|
+
* segments.
|
|
156
|
+
*
|
|
157
|
+
* What it rejects is what CR-03 actually named, and nothing more: a value that
|
|
158
|
+
* is not a plan identifier at all — a bare plan number (`01`), a task label
|
|
159
|
+
* (`T1`), a slugged identifier (`04-01-vault-trace`), or an empty string. Every
|
|
160
|
+
* one of those, written into a canonically named artifact, produces
|
|
161
|
+
* `plan_id_mismatch`.
|
|
162
|
+
*
|
|
163
|
+
* The agreement test in `tests/vault-command-router.test.cjs` and the gate-side
|
|
164
|
+
* half in `tests/vault-trace.integration.test.cjs` drive both sides over one
|
|
165
|
+
* shared value list, so an independent edit to either cannot pass.
|
|
82
166
|
*/
|
|
83
|
-
const SCAFFOLD_PLAN_ID_RE =
|
|
167
|
+
const SCAFFOLD_PLAN_ID_RE = vaultStandardsLeaf.SCAFFOLD_PLAN_ID_RE;
|
|
84
168
|
/**
|
|
85
|
-
* The required form, in ONE wording, stated in
|
|
86
|
-
* `pp-core/references/planner-vault-practices.md
|
|
87
|
-
*
|
|
169
|
+
* The required form, in ONE wording, stated in exactly three places: this
|
|
170
|
+
* rejection message, `pp-core/references/planner-vault-practices.md`, and the
|
|
171
|
+
* planner-facing inline instruction in
|
|
172
|
+
* `capabilities/vault-practices/capability.json` (04-14 T2).
|
|
173
|
+
*
|
|
174
|
+
* It is a constant rather than three sentences because a form stated three ways
|
|
175
|
+
* is three forms (threat T-4-55). The test asserts the sentence extracted
|
|
176
|
+
* independently from each document is EQUAL to the other's and to this value,
|
|
177
|
+
* rather than asserting each document merely mentions a form.
|
|
178
|
+
*
|
|
179
|
+
* ASCII only, and no backticks: the same bytes have to survive a JSON string, a
|
|
180
|
+
* markdown paragraph and a terminal, and a backtick would make the markdown
|
|
181
|
+
* copy differ from the other two.
|
|
88
182
|
*/
|
|
89
183
|
const SCAFFOLD_PLAN_ID_FORM = "The plan identifier must be the plan's phase-and-number identifier, exactly the " +
|
|
90
184
|
'prefix of its artifact filename (for example 04-02), not a bare plan number, a task ' +
|
|
91
185
|
'label or a slug.';
|
|
92
186
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
187
|
+
* The quick-task half of the form (16-05, D-29), appended to both rejection
|
|
188
|
+
* messages after `SCAFFOLD_PLAN_ID_FORM`.
|
|
189
|
+
*
|
|
190
|
+
* A SECOND constant rather than an edit to the first, and argued: the first is
|
|
191
|
+
* stated verbatim in three places, and two of them are agent context the
|
|
192
|
+
* golden set records byte for byte (the `vault-practices` plan:pre inline
|
|
193
|
+
* instruction in three cells) or that sits under an exhausted size budget
|
|
194
|
+
* (`agents/pp-executor.md`). Rewording it would re-record golden rows Phase 16
|
|
195
|
+
* may not move. Its sentence is also already true of a quick task — "exactly
|
|
196
|
+
* the prefix of its artifact filename" — so only the refusal, the one surface
|
|
197
|
+
* a caller reads after being told no, needs to name the quick form.
|
|
198
|
+
*/
|
|
199
|
+
const SCAFFOLD_QUICK_ID_FORM = 'A quick task uses its quick-task id instead, the same filename prefix (for example 261011-8yt).';
|
|
200
|
+
/**
|
|
201
|
+
* Bound on the bounded prefix read the `trace render` arm performs. The trace
|
|
202
|
+
* lives in the artifact's FRONTMATTER, so a head read is not a compromise — it
|
|
203
|
+
* is the exact region the parser needs, and a SUMMARY.md body of any size can
|
|
204
|
+
* never make this arm read more.
|
|
96
205
|
*/
|
|
97
206
|
const TRACE_ARTIFACT_READ_BYTES = 262_144;
|
|
98
207
|
/** Repo-relative artifact paths, long enough for a workstream phase tree. */
|
|
99
208
|
const TRACE_REFUSAL_PATH_CAP = 200;
|
|
100
|
-
/** A `gitCommit` digest: lowercase hex at sha1 or sha256 length. */
|
|
101
|
-
const GIT_COMMIT_DIGEST_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
|
|
102
209
|
/**
|
|
103
|
-
* Bound and control-character-collapse a caller-supplied path before it
|
|
104
|
-
*
|
|
105
|
-
* on the workflow path, an agent's context
|
|
106
|
-
* an
|
|
210
|
+
* Bound and control-character-collapse a caller-supplied path before it reaches
|
|
211
|
+
* a refusal message (T-03-53). The message reaches an operator's terminal and,
|
|
212
|
+
* on the workflow path, an agent's context — so a hostile path must not be able
|
|
213
|
+
* to forge what looks like an additional line of output, nor run to arbitrary
|
|
214
|
+
* length. Mirrors `sanitizeTraceText` in src/check-command-router.cts; that one
|
|
215
|
+
* also redacts remotes because it quotes ARTIFACT text, while this one only ever
|
|
216
|
+
* quotes the argv value the operator just typed.
|
|
107
217
|
*/
|
|
108
218
|
function boundedPath(value) {
|
|
109
219
|
const collapsed = value
|
|
@@ -117,16 +227,94 @@ function boundedPath(value) {
|
|
|
117
227
|
: collapsed;
|
|
118
228
|
}
|
|
119
229
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
230
|
+
* The gate an override recorded by the scaffold arm suppresses (04-06, SC4).
|
|
231
|
+
*
|
|
232
|
+
* `vault.status` is not a name invented here: it is the `check.query` the
|
|
233
|
+
* blocking vault gate is declared under at both write points
|
|
234
|
+
* (`capabilities/vault/capability.json`), and it is the value the freeze
|
|
235
|
+
* contract's own worked example carries (`docs/vault-trace-format.md`
|
|
236
|
+
* § The override object). Declared once so the emitted record and the
|
|
237
|
+
* documented example cannot drift apart. Its one owner is the standards leaf
|
|
238
|
+
* (15 D-35), because the standards judgement accepts an override line only
|
|
239
|
+
* under this gate name, and a second literal could drift from the first.
|
|
240
|
+
*/
|
|
241
|
+
const TRACE_OVERRIDE_GATE = vaultStandardsLeaf.STANDARDS_OVERRIDE_GATE;
|
|
242
|
+
/**
|
|
243
|
+
* A `gitCommit` digest: lowercase hex at sha1 or sha256 length. 16-06 (D-35)
|
|
244
|
+
* re-binds it to the seal leaf's `ANCHOR_SHA_RE`: a per-file digest and a
|
|
245
|
+
* record's start/end anchors are the same shape, so they have one owner. The
|
|
246
|
+
* local name stays, as an alias, so this file's readers keep their wording.
|
|
247
|
+
*/
|
|
248
|
+
const GIT_COMMIT_DIGEST_RE = vaultSeal.ANCHOR_SHA_RE;
|
|
249
|
+
// Default CoreModule implementation. _core seam overrides this for test injection.
|
|
250
|
+
const _defaultCore = { output: io.output };
|
|
251
|
+
function tracePracticesActive(cwd) {
|
|
252
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
253
|
+
const { isPracticesGateActive } = require('./vault.cjs');
|
|
254
|
+
return isPracticesGateActive(cwd);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* 16-09 (SEAL-02, D-39) — the `vault audit` verb's answer when the practices
|
|
258
|
+
* switch is off, or `null` for "print nothing" (the pre-16-09 behaviour).
|
|
259
|
+
*
|
|
260
|
+
* It asks what `check vault.audit` asks, in the gate's order (D-25), through
|
|
261
|
+
* the same readers: bindings first (no `.platform-bindings.json` never holds
|
|
262
|
+
* records, D-37/D-42); standards mode active answers nothing (the C-1 cell);
|
|
263
|
+
* the phase argument must resolve (the same `resolveTracePhaseDir`, so an
|
|
264
|
+
* argument error still prints nothing, T-03-43); then the leaf's
|
|
265
|
+
* `collectRecordedArtifacts` and `disabledWithArtifactsVerdict`, whose message
|
|
266
|
+
* is the gate's message byte for byte. Which switch is off is decided by the
|
|
267
|
+
* gate readers, never a raw config read. It blocks nothing itself: an operator
|
|
268
|
+
* surface, exit 0. There is no override.
|
|
269
|
+
*
|
|
270
|
+
* From the REAL leaf, not the `_vault` seam: a governance decision must not be
|
|
271
|
+
* switchable by a test mock (the `tracePracticesActive` rule above).
|
|
125
272
|
*/
|
|
126
|
-
function
|
|
127
|
-
|
|
273
|
+
function disabledWithArtifactsRefusal(cwd, args) {
|
|
274
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
275
|
+
const v = require('./vault.cjs');
|
|
276
|
+
let root;
|
|
277
|
+
try {
|
|
278
|
+
root = findProjectRoot(cwd) || cwd;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
root = cwd;
|
|
282
|
+
}
|
|
283
|
+
if (!v.hasPlatformBindings(root))
|
|
284
|
+
return null;
|
|
285
|
+
if (v.isStandardsModeActive(cwd))
|
|
286
|
+
return null;
|
|
287
|
+
// The audit arm's own positional rule (below): the first token that is not a
|
|
288
|
+
// flag and not the `--range` value.
|
|
289
|
+
const auditArgs = args.slice(2);
|
|
290
|
+
const rangeAt = auditArgs.indexOf('--range');
|
|
291
|
+
const phaseArg = auditArgs.find((a, i) => !a.startsWith('--') && !(rangeAt !== -1 && i === rangeAt + 1));
|
|
292
|
+
if (phaseArg === undefined)
|
|
293
|
+
return null;
|
|
294
|
+
const phase = v.resolveTracePhaseDir(root, cwd, phaseArg);
|
|
295
|
+
if (!phase)
|
|
296
|
+
return null;
|
|
297
|
+
const whichSwitch = v.isVaultGateActive(root) ? 'vault.practices_enabled' : 'vault.enabled';
|
|
298
|
+
let mode;
|
|
299
|
+
if (whichSwitch === 'vault.practices_enabled') {
|
|
300
|
+
const m = v.vaultModeVerdict(cwd);
|
|
301
|
+
if (m.active && m.ok === true && m.mode === 'practices')
|
|
302
|
+
mode = 'practices';
|
|
303
|
+
}
|
|
304
|
+
const verdict = vaultSeal.disabledWithArtifactsVerdict(vaultSeal.collectRecordedArtifacts(phase.abs, { root }), {
|
|
305
|
+
switch: whichSwitch,
|
|
306
|
+
...(mode ? { mode } : {}),
|
|
307
|
+
phaseRel: phase.rel,
|
|
308
|
+
});
|
|
309
|
+
if (!verdict)
|
|
310
|
+
return null;
|
|
311
|
+
return { status: 'disabled_with_artifacts', block: true, present: true, ...verdict };
|
|
128
312
|
}
|
|
129
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* The value token immediately following each of `flags`, by index, so a
|
|
315
|
+
* flag's value is never mistaken for a positional argument. Mirrors the
|
|
316
|
+
* `--expect` handling in the `practices promote` arm.
|
|
317
|
+
*/
|
|
130
318
|
function flagValueIndices(argv, flags) {
|
|
131
319
|
const indices = new Set();
|
|
132
320
|
for (const flag of flags) {
|
|
@@ -148,15 +336,26 @@ function flagValueIndices(argv, flags) {
|
|
|
148
336
|
function renderOrgProfileReport(report) {
|
|
149
337
|
const lines = [];
|
|
150
338
|
if (report.pointer.value === null) {
|
|
151
|
-
lines.push(
|
|
339
|
+
lines.push(report.profile.source === 'embedded'
|
|
340
|
+
? 'No org profile package is configured (org.profile is unset) — the embedded org profile applies.'
|
|
341
|
+
: 'No org profile is configured (org.profile is unset) — pp-core\'s built-in defaults apply.');
|
|
342
|
+
}
|
|
343
|
+
else if (report.pointer.value.trim().toLowerCase() === 'none') {
|
|
344
|
+
lines.push('org.profile is "none" — no org profile applies, not even the embedded one; pp-core\'s built-in defaults apply.');
|
|
152
345
|
}
|
|
153
346
|
else {
|
|
154
347
|
lines.push(`org.profile is set to "${report.pointer.value}".`);
|
|
155
348
|
}
|
|
156
|
-
if (report.profile.resolved) {
|
|
349
|
+
if (report.profile.resolved && report.profile.source === 'embedded' && report.pointer.value !== null) {
|
|
350
|
+
lines.push(`The org profile "${report.pointer.value}" could not be resolved — the embedded org profile (pp-core ${report.profile.version ?? 'unknown'}) applies instead.`);
|
|
351
|
+
}
|
|
352
|
+
else if (report.profile.resolved && report.profile.source === 'embedded') {
|
|
353
|
+
lines.push(`Using the embedded org profile shipped with pp-core ${report.profile.version ?? 'unknown'}.`);
|
|
354
|
+
}
|
|
355
|
+
else if (report.profile.resolved) {
|
|
157
356
|
lines.push(`Resolved org profile "${report.profile.name}" version ${report.profile.version ?? 'unknown'} from ${report.profile.source}.`);
|
|
158
357
|
}
|
|
159
|
-
else if (report.pointer.value !== null) {
|
|
358
|
+
else if (report.pointer.value !== null && report.pointer.value.trim().toLowerCase() !== 'none') {
|
|
160
359
|
lines.push(`The org profile "${report.pointer.value}" could not be resolved from the project or the global npm root.`);
|
|
161
360
|
}
|
|
162
361
|
const contributesKeys = Object.keys(report.contributes);
|
|
@@ -166,7 +365,8 @@ function renderOrgProfileReport(report) {
|
|
|
166
365
|
else {
|
|
167
366
|
lines.push('The org profile contributes:');
|
|
168
367
|
for (const key of contributesKeys) {
|
|
169
|
-
|
|
368
|
+
const value = report.contributes[key];
|
|
369
|
+
lines.push(` ${key} = ${value === '' ? '"" (explicitly none)' : value}`);
|
|
170
370
|
}
|
|
171
371
|
}
|
|
172
372
|
if (report.overridden.length > 0) {
|
|
@@ -192,37 +392,124 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
192
392
|
const vault = _vault ?? require('./vault.cjs');
|
|
193
393
|
const c = _core ?? _defaultCore;
|
|
194
394
|
const subcommand = args[1];
|
|
395
|
+
// 13-06 (D-08, D-22): an unrecognised `vault.mode` on a project whose vault
|
|
396
|
+
// gate exists refuses EVERY known subcommand, here, before dispatch. One owner
|
|
397
|
+
// for ten arms rather than ten copies that a new arm could forget; the test
|
|
398
|
+
// `D-15 sweep` in tests/vault-mode.test.cjs fails if the chain below and
|
|
399
|
+
// VAULT_SUBCOMMANDS ever disagree, so no arm can dodge this guard.
|
|
400
|
+
// - Keyed on VAULT_SUBCOMMANDS: an unknown subcommand never reads the mode
|
|
401
|
+
// and keeps its own message below.
|
|
402
|
+
// - The read is a config read; its one possible spawn is loadConfig's memoised, fixed-argument
|
|
403
|
+
// git check-ignore, so T-24-08b's typo never reaches the `--changed` git call. On an invalid-mode
|
|
404
|
+
// project this refusal now precedes an arm's unknown-flag error.
|
|
405
|
+
// - `vaultModeVerdict` answers `{ active: false }` without reading the mode
|
|
406
|
+
// when the gate is off, so a vault-off project is byte-identical (golden).
|
|
407
|
+
// - From the REAL leaf, not the `_vault` seam, like `isInsideDir` and
|
|
408
|
+
// `resolveTracePhaseDir` below: a governance decision must not be
|
|
409
|
+
// switchable by a test mock.
|
|
410
|
+
// - The message is the leaf's fixed text: it names both allowed values and
|
|
411
|
+
// never echoes the configured one (T-13-03).
|
|
412
|
+
// - Quick 261015-ect (QD-11): only where `.platform-bindings.json` exists. A
|
|
413
|
+
// project without it never reads the mode (T-16-13): admission's own step
|
|
414
|
+
// 1, read the same way, and the order every `check` vault handler keeps
|
|
415
|
+
// (bindings-inert first, D-22). The 1.16.0 release note stated the old
|
|
416
|
+
// order as measured; this reverses it, and closes `/pp-fast`'s backstop,
|
|
417
|
+
// which asks `vault admission` with no bindings probe of its own.
|
|
418
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
419
|
+
const modeSeam = require('./vault.cjs');
|
|
420
|
+
if (VAULT_SUBCOMMANDS.includes(subcommand) && modeSeam.hasPlatformBindings(cwd)) {
|
|
421
|
+
const modeVerdict = modeSeam.vaultModeVerdict(cwd);
|
|
422
|
+
if (modeVerdict.active && modeVerdict.ok === false) {
|
|
423
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
424
|
+
const leaf = require('./vault-mode.cjs');
|
|
425
|
+
// 13-REVIEW WR-01: a resolver failure is refused like an invalid value,
|
|
426
|
+
// under its own typed reason.
|
|
427
|
+
const reason = modeVerdict.reason ?? '';
|
|
428
|
+
error(leaf.vaultModeRefusalMessage(reason), reason === leaf.VAULT_MODE_REASON.UNRESOLVED ? ERROR_REASON.VAULT_MODE_UNRESOLVED : ERROR_REASON.VAULT_MODE_INVALID);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
195
432
|
if (subcommand === 'fetch') {
|
|
196
433
|
c.output(vault.vaultFetch(cwd), raw);
|
|
197
434
|
}
|
|
198
435
|
else if (subcommand === 'status') {
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
436
|
+
// 13-06 (D-10): `mode: standards` LAST, in standards mode only; the same
|
|
437
|
+
// object otherwise, so a practices-mode envelope gains no key (golden).
|
|
438
|
+
// 14-05 (D-11/D-14): the same five flat fields `check vault.status` carries,
|
|
439
|
+
// from the one state function, so the report and the gate cannot tell
|
|
440
|
+
// different stories (UAT F6). From the REAL module (`modeSeam`), not the
|
|
441
|
+
// injectable `vault` seam: a governance answer must not be switchable by a
|
|
442
|
+
// test mock (13-06 precedent). No `block` and no `reason`: this is a report.
|
|
443
|
+
// The state is null outside standards mode, so practices gains no key.
|
|
444
|
+
const base = vault.vaultStatus(cwd);
|
|
445
|
+
const indexState = modeSeam.computeVaultIndexState(cwd);
|
|
446
|
+
const withIndex = indexState !== null && base['present'] === true
|
|
447
|
+
? { ...base, ...modeSeam.vaultIndexEnvelopeFields(indexState) }
|
|
448
|
+
: base;
|
|
449
|
+
c.output(modeSeam.withVaultMode(withIndex, cwd), raw);
|
|
204
450
|
}
|
|
205
451
|
else if (subcommand === 'probe') {
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
452
|
+
// STRICT-02: pure reachability for the client vault, the shared fallback and
|
|
453
|
+
// the platform repo — no clone, no cache mutation.
|
|
454
|
+
//
|
|
455
|
+
// T-02-07: reject any unrecognized `--` token BEFORE anything spawns, copying
|
|
456
|
+
// the `practices match` posture below verbatim. A typo must never reach a
|
|
457
|
+
// subprocess. The host (pp-core/bin/pp-tools.cjs) already splices out its own
|
|
458
|
+
// global flags before dispatch, which is why the recognized set is one wide.
|
|
459
|
+
const RECOGNIZED_PROBE_FLAGS = new Set(['--raw']);
|
|
460
|
+
const unrecognizedProbeFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_PROBE_FLAGS.has(a));
|
|
461
|
+
if (unrecognizedProbeFlag !== undefined) {
|
|
462
|
+
error(`Unrecognized flag "${unrecognizedProbeFlag}" for vault probe. Supported: --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
// The probe's ls-remote names its remote by URL, but git still reads the config of the repository the
|
|
466
|
+
// environment selects (measured: `protocol.file.allow=never` in a pointed-to repository's config made the
|
|
467
|
+
// bare runner's ls-remote fail with `transport 'file' not allowed`, while the same call with the location
|
|
468
|
+
// unset answered), so it runs through the owner; transport is kept (D-117).
|
|
469
|
+
c.output(vault.vaultProbe(cwd, { exec: _exec ?? shell_command_projection_cjs_1.execGitLocal }), raw);
|
|
470
|
+
}
|
|
471
|
+
else if (subcommand === 'summary') {
|
|
472
|
+
// STRICT-05 (02-02): read the STATUS, not the bare string. The write failure
|
|
473
|
+
// used to be swallowed inside vaultSummary, so this arm could only ever print
|
|
474
|
+
// success. It still exits 0 on every path — per D-VH-7 the vault and check
|
|
475
|
+
// commands REPORT and the workflow halts; making this exit non-zero would
|
|
476
|
+
// route the block into the step's `onError: skip` and reopen the fail-open.
|
|
477
|
+
const summary = vault.vaultSummaryResult(cwd);
|
|
478
|
+
if (!summary.ok) {
|
|
479
|
+
// Warning to stderr so the success-path stdout stays byte-identical to
|
|
480
|
+
// before this plan for every healthy project.
|
|
481
|
+
process.stderr.write(`⚠ vault summary: could not write ${summary.path ?? '.planning/vault/SUMMARY.md'} — ${summary.error ?? 'unknown error'}\n`);
|
|
210
482
|
}
|
|
211
|
-
|
|
212
|
-
c.output(
|
|
483
|
+
if (raw) {
|
|
484
|
+
c.output(summary, true, JSON.stringify(summary));
|
|
213
485
|
}
|
|
214
486
|
else {
|
|
215
|
-
c.output(
|
|
487
|
+
c.output(summary.index, false);
|
|
216
488
|
}
|
|
217
489
|
}
|
|
218
|
-
else if (subcommand === 'summary') {
|
|
219
|
-
c.output(vault.vaultSummary(cwd), raw);
|
|
220
|
-
}
|
|
221
490
|
else if (subcommand === 'practices') {
|
|
222
491
|
const verb = args[2];
|
|
223
492
|
if (verb === 'match') {
|
|
493
|
+
// Quick 261015-at3 (QD-02, todo practices-record-verbs-print-without-
|
|
494
|
+
// bindings): unless `vault admission` answers `on` this arm prints
|
|
495
|
+
// nothing, FIRST — before the flag guard and before the mode read. It was
|
|
496
|
+
// gated by no switch at all (measured 261014-na1: it printed a block on
|
|
497
|
+
// `vault.enabled: false`), so a record no gate reads could be produced on
|
|
498
|
+
// a project whose `check ship.practices` answers inert.
|
|
499
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
500
|
+
const owner = require('./vault.cjs');
|
|
501
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
502
|
+
return;
|
|
503
|
+
// Quick 261015-ect (QD-14, at3 verifier W-1): in practices mode, silent
|
|
504
|
+
// where `check ship.practices` answers inert — the practices switch off —
|
|
505
|
+
// as the trace, audit and manifest arms already are: a block no gate
|
|
506
|
+
// reads is not produced. Standards mode keeps D-22's answer below on
|
|
507
|
+
// either switch, because the load-gate card relays that line as its
|
|
508
|
+
// standards-first clause (15-11) and it records no practice.
|
|
509
|
+
if (!modeSeam.isStandardsModeActive(cwd) && !tracePracticesActive(cwd))
|
|
510
|
+
return;
|
|
224
511
|
// T-24-08b: reject any unrecognized `--`-prefixed argument BEFORE the
|
|
225
|
-
// `--changed` git call (so a typo never spawns
|
|
512
|
+
// `--changed` git call (so a typo never spawns the diff) instead
|
|
226
513
|
// of silently filtering it out of the positional file list. Matched on
|
|
227
514
|
// the WHOLE token so `--files=…` is caught by the same check as
|
|
228
515
|
// `--files value`. DECISION (recorded, not deferred): `--files=` is
|
|
@@ -240,9 +527,26 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
240
527
|
error(`Unrecognized flag "${unrecognizedFlag}" for vault practices match. Supported: positional file paths, --changed, --raw, or --block.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
241
528
|
return;
|
|
242
529
|
}
|
|
530
|
+
// 13-07 (D-12, PARK-01): in standards mode the matcher has no practice
|
|
531
|
+
// layer to read, so this arm answers not-applicable — one line naming the
|
|
532
|
+
// mode, or `--raw`'s envelope with NO match-set key, exit 0. AFTER the
|
|
533
|
+
// flag guard (a typo still fails first, T-24-08b) and BEFORE the
|
|
534
|
+
// `--changed` git call (a parked path spawns no diff, T-13-12). `--block`
|
|
535
|
+
// is non-raw, so it prints the same line and no `<vault_practices>` block.
|
|
536
|
+
// There is deliberately NO activation guard on this branch: the arm
|
|
537
|
+
// answers not-applicable whenever the vault gate is active, even with
|
|
538
|
+
// `vault.practices_enabled` false (D-22; QD-14 above gates practices mode
|
|
539
|
+
// only). The text is the leaf's.
|
|
540
|
+
if (modeSeam.isStandardsModeActive(cwd)) {
|
|
541
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
542
|
+
const { notApplicableMatchEnvelope } = require('./vault-mode.cjs');
|
|
543
|
+
const naMatch = notApplicableMatchEnvelope();
|
|
544
|
+
c.output(naMatch, true, raw ? JSON.stringify(naMatch) : naMatch.message);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
243
547
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
244
548
|
const practices = _practices ?? require('./vault-practices.cjs');
|
|
245
|
-
const runGit = _exec ?? shell_command_projection_cjs_1.
|
|
549
|
+
const runGit = _exec ?? shell_command_projection_cjs_1.execGitLocal;
|
|
246
550
|
let files;
|
|
247
551
|
if (args.includes('--changed')) {
|
|
248
552
|
const gitResult = runGit(['diff', '--name-only', 'HEAD'], { cwd });
|
|
@@ -341,6 +645,8 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
341
645
|
const coordinates = _coordinates ?? require('./vault-coordinates.cjs');
|
|
342
646
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
343
647
|
const providerModule = _provider ?? require('./git-provider.cjs');
|
|
648
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
649
|
+
const { VAULT_MODE_CONFIG_KEY: vaultModeKey } = require('./vault-mode.cjs');
|
|
344
650
|
const resolution = orgProfile.resolveOrgProfile(cwd);
|
|
345
651
|
// Resolve the EFFECTIVE values by calling the real resolvers, so the
|
|
346
652
|
// report shows what actually won rather than what the profile offered.
|
|
@@ -353,13 +659,32 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
353
659
|
const platformHost = coordinates.resolvePlatformHost(cwd);
|
|
354
660
|
const effectiveByKey = {
|
|
355
661
|
'vault.provider': provider,
|
|
356
|
-
|
|
662
|
+
// An explicit opt-out (an empty string, from config or profile) reads
|
|
663
|
+
// back as "" rather than null, so it is not reported as overridden.
|
|
664
|
+
[coordinates.SHARED_REPO_CONFIG_KEY]: shared.repo ?? (shared.suppressedKind === 'explicit-opt-out' ? '' : null),
|
|
357
665
|
[coordinates.SHARED_REF_CONFIG_KEY]: shared.ref,
|
|
358
666
|
[coordinates.SHARED_HOST_CONFIG_KEY]: shared.host,
|
|
359
667
|
[coordinates.PLATFORM_HOST_CONFIG_KEY]: platformHost.host,
|
|
360
|
-
//
|
|
361
|
-
//
|
|
668
|
+
// 13-05 / D-20: the two POLICY keys, through their own resolvers.
|
|
669
|
+
// Without these rows both read null, and a profile-seeded value that
|
|
670
|
+
// actually won was reported as `profile offered "true", but "null"
|
|
671
|
+
// won.` (reproduced on 1.15.1) — a diagnostic stating something untrue.
|
|
672
|
+
// The profile carries `vault.required` as the canonical string
|
|
673
|
+
// 'true'/'false', so String(required) compares equal when it won.
|
|
674
|
+
// `resolveVaultRequired` is the SAME ladder the load gate asks
|
|
675
|
+
// (13-REVIEW CR-02): workstream then root config, trimmed, and an
|
|
676
|
+
// unparseable project value falls through to the profile — so the
|
|
677
|
+
// report cannot say the profile lost while the gate enforces it. It is
|
|
678
|
+
// the policy only; with `vault.enabled` off no gate exists to apply it.
|
|
362
679
|
[orgProfile.VAULT_REQUIRED_CONFIG_KEY]: String(orgProfile.resolveVaultRequired(cwd).required),
|
|
680
|
+
// An invalid PROJECT mode reads as the typed reason
|
|
681
|
+
// (`vault_mode_invalid`), never the value: the project's invalid value
|
|
682
|
+
// did win over the profile, and the report says so without repeating
|
|
683
|
+
// operator text (T-13-03).
|
|
684
|
+
[vaultModeKey]: (() => {
|
|
685
|
+
const r = orgProfile.resolveVaultMode(cwd);
|
|
686
|
+
return r.ok ? r.mode : r.reason;
|
|
687
|
+
})(),
|
|
363
688
|
};
|
|
364
689
|
// Build `effective`'s key list from ORG_PROFILE_ALLOWLIST rather than a
|
|
365
690
|
// fresh literal array, so a future allowlist change surfaces here
|
|
@@ -380,21 +705,15 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
380
705
|
overridden.push({ key, profile: profileValue, effective: effectiveValue });
|
|
381
706
|
}
|
|
382
707
|
}
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const
|
|
392
|
-
for (const key of Object.keys(resolution.contributes)) {
|
|
393
|
-
shownContributes[key] = shownRepo(key, resolution.contributes[key]) ?? '';
|
|
394
|
-
}
|
|
395
|
-
const shownEffective = {};
|
|
396
|
-
for (const key of Object.keys(effective))
|
|
397
|
-
shownEffective[key] = shownRepo(key, effective[key]);
|
|
708
|
+
// VAULT-EGRESS-1 (quick 261005-8py): a credential embedded in
|
|
709
|
+
// `vault.fallback_repo` must not reach stdout. `overridden` above compares
|
|
710
|
+
// the RAW values, so redaction happens here, at emission only, through the
|
|
711
|
+
// one owner — never a second redactor.
|
|
712
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
713
|
+
const { redactCloneTarget } = require('./vault.cjs');
|
|
714
|
+
const safe = (key, value) => (key === coordinates.SHARED_REPO_CONFIG_KEY && typeof value === 'string' && value !== ''
|
|
715
|
+
? (redactCloneTarget(value) ?? value) : value);
|
|
716
|
+
const safeMap = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, safe(k, v)]));
|
|
398
717
|
const report = {
|
|
399
718
|
pointer: { key: orgProfile.ORG_PROFILE_CONFIG_KEY, value: resolution.configured },
|
|
400
719
|
profile: {
|
|
@@ -404,16 +723,12 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
404
723
|
source: resolution.source,
|
|
405
724
|
dataPath: resolution.dataPath,
|
|
406
725
|
},
|
|
407
|
-
contributes:
|
|
408
|
-
effective:
|
|
409
|
-
overridden: overridden.map((o) => ({
|
|
410
|
-
key: o.key,
|
|
411
|
-
profile: shownRepo(o.key, o.profile) ?? '',
|
|
412
|
-
effective: shownRepo(o.key, o.effective),
|
|
413
|
-
})),
|
|
726
|
+
contributes: safeMap(resolution.contributes),
|
|
727
|
+
effective: safeMap(effective),
|
|
728
|
+
overridden: overridden.map((o) => ({ ...o, profile: safe(o.key, o.profile), effective: safe(o.key, o.effective) })),
|
|
414
729
|
ignored: resolution.ignored,
|
|
415
730
|
rejected: resolution.rejected,
|
|
416
|
-
warnings: resolution.warnings
|
|
731
|
+
warnings: resolution.warnings,
|
|
417
732
|
};
|
|
418
733
|
// Never call error() for a project with no profile, and never exit
|
|
419
734
|
// non-zero for a resolution failure — this is a diagnostic, not an
|
|
@@ -427,21 +742,621 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
427
742
|
}
|
|
428
743
|
}
|
|
429
744
|
else if (subcommand === 'trace') {
|
|
430
|
-
|
|
745
|
+
// ── The practice-trace producer (Phase 03-05, TRACE-01/02/03) ──
|
|
746
|
+
//
|
|
747
|
+
// D-01's split, mechanised: the CLI owns everything that must be PROVABLE
|
|
748
|
+
// — the pinned `ref`, the `resolvedSha` and the considered practice-ID set
|
|
749
|
+
// — and the agent writes only the judgement half (`status` + `reason`).
|
|
750
|
+
// "Provable" cannot rest on an agent transcribing a forty-character SHA
|
|
751
|
+
// correctly, and a well-formedness gate by definition cannot catch a
|
|
752
|
+
// wrong-but-well-formed SHA.
|
|
753
|
+
//
|
|
754
|
+
// BOTH arms below are pass-throughs to `./vault-trace.cjs`. They collect
|
|
755
|
+
// arguments, call through, and emit. They hold NO envelope logic, NO glob
|
|
756
|
+
// pass and NO second read of the manifest: every provable field is the
|
|
757
|
+
// leaf module's business, and duplicating any of it here would create a
|
|
758
|
+
// second place where the trace can disagree with the gate that validates
|
|
759
|
+
// it.
|
|
760
|
+
//
|
|
761
|
+
// NEITHER ARM WRITES A FILE, and that is a requirement rather than a
|
|
762
|
+
// convenience not yet added. The producer emits text for an agent to embed
|
|
763
|
+
// in the single write it is already performing. A CLI that patched a
|
|
764
|
+
// plan's frontmatter would have to round-trip it through the shared
|
|
765
|
+
// serializer, which this repository documents as "a deliberately lossy
|
|
766
|
+
// serializer — it cannot faithfully re-emit nested object-list items" —
|
|
767
|
+
// so the same write that added the trace would destroy the plan's own
|
|
768
|
+
// must-haves block (T-03-23). If a direct write is ever wanted it must be a
|
|
769
|
+
// raw text splice on the delimited region, and that is not this phase's
|
|
770
|
+
// business. Do not add the convenience.
|
|
771
|
+
//
|
|
772
|
+
// ORDERING, and why it is this way round: the sub-verb check comes first,
|
|
773
|
+
// so a mistyped sub-verb is refused with the usage error, bindings or not,
|
|
774
|
+
// and produces no record (quick 261015-ect, QD-12 — measured and pinned).
|
|
775
|
+
// Then the admission verdict and the capability gate (quick 261015-at3 put
|
|
776
|
+
// the admission line ahead of the gate), ahead of the flag allowlist. The
|
|
777
|
+
// two orders both satisfy T-03-25 (nothing is required and nothing spawns
|
|
778
|
+
// before an unknown flag is rejected), because the gate is a bounded config
|
|
779
|
+
// read, not a subprocess. Only this order also satisfies D-04's
|
|
780
|
+
// non-negotiable clause — for every VALID sub-verb an inert project's
|
|
781
|
+
// output is byte-identical, on every flag path including a mistyped one
|
|
782
|
+
// (T-03-26).
|
|
783
|
+
const verb = args[2];
|
|
784
|
+
const traceArgs = args.slice(3);
|
|
785
|
+
if (verb !== 'scaffold' && verb !== 'render') {
|
|
786
|
+
error(`Unknown trace subcommand. Available: ${TRACE_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
// Quick 261015-at3: the admission verdict precedes the capability gate, as
|
|
790
|
+
// in `routeVaultAnchor` — no `.platform-bindings.json` means `check
|
|
791
|
+
// vault.trace` answers inert, so no trace may be produced either.
|
|
792
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
793
|
+
const owner = require('./vault.cjs');
|
|
794
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
795
|
+
return;
|
|
796
|
+
if (!tracePracticesActive(cwd))
|
|
797
|
+
return;
|
|
798
|
+
if (verb === 'scaffold') {
|
|
799
|
+
const RECOGNIZED_SCAFFOLD_FLAGS = new Set(['--stage', '--plan', '--anchors-dir', '--raw']);
|
|
800
|
+
const unrecognizedScaffoldFlag = traceArgs.find((a) => a.startsWith('--') && !RECOGNIZED_SCAFFOLD_FLAGS.has(a));
|
|
801
|
+
if (unrecognizedScaffoldFlag !== undefined) {
|
|
802
|
+
error(`Unrecognized flag "${unrecognizedScaffoldFlag}" for vault trace scaffold. ` +
|
|
803
|
+
`Supported: positional file paths, --stage <${TRACE_STAGES.join('|')}>, --plan <id>, ` +
|
|
804
|
+
'--anchors-dir <dir> (execute stage), --raw.', ERROR_REASON.SDK_FAIL_FAST);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
// Quick 261015-ect (QD-15): parked in standards mode, beside `practices
|
|
808
|
+
// match` (PARK-01) and `vault audit` (PARK-02). A standards-mode plan
|
|
809
|
+
// carries no practices ledger: `check ship.practices`, `check
|
|
810
|
+
// vault.audit` and `check vault.review` answer not_applicable here, and
|
|
811
|
+
// the standards `check vault.trace` reads `<vault_standards>` blocks, not
|
|
812
|
+
// this ledger. After admission, the practices gate and this verb's flag
|
|
813
|
+
// allowlist (a typo still fails first, as in the match and audit arms),
|
|
814
|
+
// before any read; one fixed line from the leaf, exit 0, nothing written.
|
|
815
|
+
if (modeSeam.isStandardsModeActive(cwd)) {
|
|
816
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
817
|
+
const { notApplicableTraceVerbEnvelope } = require('./vault-mode.cjs');
|
|
818
|
+
const naTrace = notApplicableTraceVerbEnvelope();
|
|
819
|
+
c.output(naTrace, true, raw ? JSON.stringify(naTrace) : naTrace.message);
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
const valueIndices = flagValueIndices(traceArgs, ['--stage', '--plan', '--anchors-dir']);
|
|
823
|
+
const stageIdx = traceArgs.indexOf('--stage');
|
|
824
|
+
const stage = stageIdx !== -1 ? traceArgs[stageIdx + 1] : undefined;
|
|
825
|
+
// A missing stage and an invalid one share a reason deliberately: both
|
|
826
|
+
// are "this invocation cannot be interpreted", and splitting them would
|
|
827
|
+
// make the operator read two messages to learn the same two words.
|
|
828
|
+
if (stage === undefined || !TRACE_STAGES.includes(stage)) {
|
|
829
|
+
error(`Missing or unrecognized --stage value for vault trace scaffold. ` +
|
|
830
|
+
`Supported: ${TRACE_STAGES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const planIdx = traceArgs.indexOf('--plan');
|
|
834
|
+
const planId = planIdx !== -1 ? traceArgs[planIdx + 1] : undefined;
|
|
835
|
+
if (planId === undefined || planId.startsWith('--')) {
|
|
836
|
+
error('No plan identifier was given. Usage: vault trace scaffold ' +
|
|
837
|
+
`--stage <${TRACE_STAGES.join('|')}> --plan <id> [files...] [--raw].`, ERROR_REASON.SDK_MISSING_ARG);
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
// 04-14 (TGATE-01, CR-03): the value is PRESENT but is not a plan
|
|
841
|
+
// identifier. Its own rejection, below both guards and beside the other
|
|
842
|
+
// two argument rejections, so a vault-disabled project still reaches
|
|
843
|
+
// none of them.
|
|
844
|
+
//
|
|
845
|
+
// THE REASON IS THE ESTABLISHED ONE, not a third vocabulary entry. It is
|
|
846
|
+
// `sdk_fail_fast`, the same reason the stage validation above uses,
|
|
847
|
+
// because this is the same class of defect: a value was supplied and
|
|
848
|
+
// cannot be interpreted. `sdk_missing_arg` belongs to the branch directly
|
|
849
|
+
// above, where the value was consumed by a following flag and no
|
|
850
|
+
// identifier reached the arm at all.
|
|
851
|
+
//
|
|
852
|
+
// IT TEACHES RATHER THAN REFUSING. The message names the value that was
|
|
853
|
+
// given, the required form and one conforming example, and then says what
|
|
854
|
+
// the gate will do with the value — because a caller who is told only
|
|
855
|
+
// that a value is invalid has to guess, and the guess CR-03 documents is
|
|
856
|
+
// the one this rejection exists to stop.
|
|
857
|
+
//
|
|
858
|
+
// FENCE 3 is untouched: this arm still writes no file, and a rejection
|
|
859
|
+
// returns before the leaf is required, so nothing is produced and nothing
|
|
860
|
+
// is partially produced.
|
|
861
|
+
if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
|
|
862
|
+
error(`Invalid --plan value "${planId}" for vault trace scaffold. ` +
|
|
863
|
+
`${SCAFFOLD_PLAN_ID_FORM} ${SCAFFOLD_QUICK_ID_FORM} The trace gate compares the identifier a plan artifact ` +
|
|
864
|
+
'records against the one its own filename states, and blocks on a disagreement.', ERROR_REASON.SDK_FAIL_FAST);
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
const files = traceArgs.filter((a, i) => !a.startsWith('--') && !valueIndices.has(i));
|
|
868
|
+
// The execute stage resolves a `gitCommit` digest per governed file, so
|
|
869
|
+
// the statement asserts something falsifiable: these practices governed
|
|
870
|
+
// THESE artifacts at this vault SHA (D-08). The digest is the commit
|
|
871
|
+
// that last produced the artifact, not merely HEAD, so a subject entry
|
|
872
|
+
// points at the work it actually describes. A file with no commit yields
|
|
873
|
+
// no digest — deliberately, because the leaf module records that gap as
|
|
874
|
+
// a LOUD note inside the statement, and a HEAD fallback would paper over
|
|
875
|
+
// a real coverage hole with a plausible-looking value.
|
|
876
|
+
const runGit = _exec ?? shell_command_projection_cjs_1.execGitLocal;
|
|
877
|
+
// 16-06 (D-14/D-31/D-35): the execute-stage record's anchors, read by the
|
|
878
|
+
// CLI and never typed. The END anchor is HEAD now; the START anchor is
|
|
879
|
+
// the one `vault anchor start` recorded in the plan's directory at
|
|
880
|
+
// execute:pre (phase) or where quick captured its base. `--anchors-dir`
|
|
881
|
+
// names that directory explicitly; otherwise it is derived from `--plan`
|
|
882
|
+
// (`resolveScaffoldAnchorsDir`). The plan stage reads neither and its
|
|
883
|
+
// output is byte-identical (golden-pinned).
|
|
884
|
+
const anchorsDirIdx = traceArgs.indexOf('--anchors-dir');
|
|
885
|
+
let anchors = null;
|
|
886
|
+
if (anchorsDirIdx !== -1) {
|
|
887
|
+
const anchorsDirArg = traceArgs[anchorsDirIdx + 1];
|
|
888
|
+
if (stage !== 'execute') {
|
|
889
|
+
error('--anchors-dir applies to --stage execute only; a plan-stage ledger records no anchors.', ERROR_REASON.SDK_FAIL_FAST);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (anchorsDirArg === undefined || anchorsDirArg.startsWith('--')) {
|
|
893
|
+
error('--anchors-dir needs a directory under .planning/. Usage: vault trace scaffold --stage execute --plan <id> --anchors-dir <dir> [files...].', ERROR_REASON.SDK_MISSING_ARG);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (stage === 'execute') {
|
|
898
|
+
const head = runGit(['rev-parse', 'HEAD'], { cwd });
|
|
899
|
+
const end = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
900
|
+
anchors = {};
|
|
901
|
+
if (vaultSeal.ANCHOR_SHA_RE.test(end))
|
|
902
|
+
anchors.endCommit = end;
|
|
903
|
+
const located = anchorsDirIdx !== -1
|
|
904
|
+
? resolvePlanningPath(cwd, traceArgs[anchorsDirIdx + 1], 'dir')
|
|
905
|
+
: resolveScaffoldAnchorsDir(cwd, planId);
|
|
906
|
+
if (anchorsDirIdx !== -1 && !located.ok) {
|
|
907
|
+
error(`--anchors-dir ${located.message}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const start = located.ok ? vaultSeal.readDirAnchors(located.abs)?.start : undefined;
|
|
911
|
+
if (start !== undefined) {
|
|
912
|
+
anchors.startCommit = start;
|
|
913
|
+
}
|
|
914
|
+
else {
|
|
915
|
+
// A note, not an error: the record is still written, with its END
|
|
916
|
+
// anchor, and the strict points type the missing start (16-07). One
|
|
917
|
+
// line on stderr, so the embedded block on stdout is unchanged.
|
|
918
|
+
process.stderr.write(`vault trace scaffold: no start anchor recorded for plan ${boundedPath(planId)} ` +
|
|
919
|
+
`(${located.ok ? `${located.rel} holds no ${vaultSeal.VAULT_ANCHORS_FILE}` : located.message}); ` +
|
|
920
|
+
'the ledger carries endCommit only. `pp_run vault anchor start <dir>` records it at execute:pre.\n');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const fileInputs = files.map((name) => {
|
|
924
|
+
if (stage !== 'execute')
|
|
925
|
+
return name;
|
|
926
|
+
const res = runGit(['log', '-1', '--format=%H', '--', name], { cwd });
|
|
927
|
+
const digest = res.exitCode === 0 ? res.stdout.trim() : '';
|
|
928
|
+
return GIT_COMMIT_DIGEST_RE.test(digest) ? { name, gitCommit: digest } : name;
|
|
929
|
+
});
|
|
930
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
931
|
+
const traceMod = _trace ?? require('./vault-trace.cjs');
|
|
932
|
+
// No files is a REAL path, not an error: a documentation-only plan is
|
|
933
|
+
// still governed by the global practices, and emitting nothing there
|
|
934
|
+
// would make the ruled-out set unauditable for exactly the plans where a
|
|
935
|
+
// reviewer most wants to know what was considered.
|
|
936
|
+
//
|
|
937
|
+
// THE OVERRIDE CONTEXT (04-06, SC4 / D-13 / D-14 / D-15).
|
|
938
|
+
//
|
|
939
|
+
// The verdict is obtained HERE rather than inside the leaf, and the
|
|
940
|
+
// reason is the same one that put the digest mapping above in the arm:
|
|
941
|
+
// this is the only place that knows a trace is being written NOW, and an
|
|
942
|
+
// override record is a statement about now. `buildTraceStatement`
|
|
943
|
+
// reaching for a live verdict from inside itself would make a pure
|
|
944
|
+
// producer's output depend on the machine's vault state through a path
|
|
945
|
+
// its signature does not declare — the producer/judge split D-01 drew,
|
|
946
|
+
// undone.
|
|
947
|
+
//
|
|
948
|
+
// The AGENT is never the courier for these three fields. Were the gate,
|
|
949
|
+
// the block and the suppressed reason passed as flags, a bypass could be
|
|
950
|
+
// concealed by simply not passing one — and the record that a bypass
|
|
951
|
+
// happened is the single field a bypass most wants hidden. The CLI
|
|
952
|
+
// computes all three, so there is nothing for a caller to omit.
|
|
953
|
+
//
|
|
954
|
+
// D-13: the arm runs once per stage per plan, and each invocation
|
|
955
|
+
// re-reads the environment and re-computes the verdict at its own
|
|
956
|
+
// timestamp. A bypass taken at execution but not at planning is
|
|
957
|
+
// therefore a distinct visible event, not an inference.
|
|
958
|
+
//
|
|
959
|
+
// The common path is unchanged. `buildOverrideRecord` returns null unless
|
|
960
|
+
// the verdict GENUINELY blocked *and* an accepted override reason is
|
|
961
|
+
// present in the environment; a non-blocking verdict with the reason set
|
|
962
|
+
// still records nothing, because recording one would manufacture evidence
|
|
963
|
+
// of a bypass that never happened (D-15).
|
|
964
|
+
//
|
|
965
|
+
// Obtaining a verdict is a READ. `computeVaultVerdict` never fetches or
|
|
966
|
+
// throws (its one possible spawn is a read-only git check-ignore), and this arm still writes no file and
|
|
967
|
+
// splices no frontmatter (FENCE 3).
|
|
968
|
+
const verdict = vault.computeVaultVerdict(cwd);
|
|
969
|
+
const statement = traceMod.buildTraceStatement({
|
|
970
|
+
cwd,
|
|
971
|
+
stage,
|
|
972
|
+
plan: planId,
|
|
973
|
+
files: fileInputs,
|
|
974
|
+
override: {
|
|
975
|
+
gate: TRACE_OVERRIDE_GATE,
|
|
976
|
+
block: verdict.block,
|
|
977
|
+
suppressedReason: verdict.reason,
|
|
978
|
+
},
|
|
979
|
+
// Spread, never `anchors: null` on the plan stage: a seam that records
|
|
980
|
+
// its arguments (the router tests' trace mock) then sees the call it
|
|
981
|
+
// saw before 16-06.
|
|
982
|
+
...(anchors !== null ? { anchors } : {}),
|
|
983
|
+
});
|
|
984
|
+
// The three-way emit the `practices match --block` arm established: the
|
|
985
|
+
// structured object is ALWAYS the output value, and the text a consumer
|
|
986
|
+
// embeds is the third argument. The planner embeds the block VERBATIM —
|
|
987
|
+
// it never renames a field and never re-derives a value, which is the
|
|
988
|
+
// whole point of D-01's split.
|
|
989
|
+
if (raw) {
|
|
990
|
+
c.output(statement, true, JSON.stringify(statement));
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
c.output(statement, true, traceMod.serializeTraceBlock(statement));
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
const RECOGNIZED_RENDER_FLAGS = new Set(['--raw']);
|
|
998
|
+
const unrecognizedRenderFlag = traceArgs.find((a) => a.startsWith('--') && !RECOGNIZED_RENDER_FLAGS.has(a));
|
|
999
|
+
if (unrecognizedRenderFlag !== undefined) {
|
|
1000
|
+
error(`Unrecognized flag "${unrecognizedRenderFlag}" for vault trace render. ` +
|
|
1001
|
+
'Supported: a positional artifact path, --raw.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
// Quick 261015-ect (QD-15): parked in standards mode, as the scaffold
|
|
1005
|
+
// branch above is and for the same reason (no practices ledger is read).
|
|
1006
|
+
if (modeSeam.isStandardsModeActive(cwd)) {
|
|
1007
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1008
|
+
const { notApplicableTraceVerbEnvelope } = require('./vault-mode.cjs');
|
|
1009
|
+
const naRender = notApplicableTraceVerbEnvelope();
|
|
1010
|
+
c.output(naRender, true, raw ? JSON.stringify(naRender) : naRender.message);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
const artifactPath = traceArgs.find((a) => !a.startsWith('--'));
|
|
1014
|
+
if (artifactPath === undefined) {
|
|
1015
|
+
error('No artifact path was given. Usage: vault trace render <artifact> [--raw].', ERROR_REASON.SDK_MISSING_ARG);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
1018
|
+
// CONTAINMENT (CR-04, T-03-50/51/52). `artifactPath` is an unvalidated
|
|
1019
|
+
// positional and the read below is unconditional, so before this branch
|
|
1020
|
+
// existed `vault trace render ../outside/secret.md` emitted an out-of-tree
|
|
1021
|
+
// file's content as the `## Practices Applied` section that
|
|
1022
|
+
// `agents/pp-executor.md` instructs the executor to include VERBATIM in
|
|
1023
|
+
// SUMMARY.md — an arbitrary file's bytes becoming governance-artifact
|
|
1024
|
+
// content a later reviewer agent reads.
|
|
1025
|
+
//
|
|
1026
|
+
// It sits HERE, inside the render arm, BELOW the `tracePracticesActive`
|
|
1027
|
+
// early return above — and must stay there. That return is the single
|
|
1028
|
+
// guard making the whole trace verb inert on a capability-off project, so
|
|
1029
|
+
// no containment decision, no project-root resolution and no read may be
|
|
1030
|
+
// reachable above it or an inert project stops being byte-identically
|
|
1031
|
+
// silent (D-04, T-03-26). Do not hoist any of this for a cheaper argument
|
|
1032
|
+
// check, and do not share a resolved root with the scaffold arm.
|
|
1033
|
+
//
|
|
1034
|
+
// It also sits BEFORE the read rather than being inferred from it:
|
|
1035
|
+
// `readHeadBounded` returns the empty string on any problem and never
|
|
1036
|
+
// throws, so a decision taken on its RESULT would be no decision at all.
|
|
1037
|
+
//
|
|
1038
|
+
// The predicate comes from the REAL leaf, not the `_vault` seam: a
|
|
1039
|
+
// security decision must not be switchable by a test mock. Same
|
|
1040
|
+
// `require('./vault.cjs')` the seam itself defaults to — one module, not
|
|
1041
|
+
// a second import path.
|
|
1042
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1043
|
+
const { isInsideDir } = require('./vault.cjs');
|
|
1044
|
+
if (artifactPath.indexOf('\u0000') !== -1) {
|
|
1045
|
+
error(`The artifact path is not a usable path: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
let projectRoot;
|
|
1049
|
+
try {
|
|
1050
|
+
projectRoot = findProjectRoot(cwd) || cwd;
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
projectRoot = cwd;
|
|
1054
|
+
}
|
|
1055
|
+
let artifactAbs;
|
|
1056
|
+
try {
|
|
1057
|
+
artifactAbs = node_path_1.default.resolve(cwd, artifactPath);
|
|
1058
|
+
}
|
|
1059
|
+
catch {
|
|
1060
|
+
error(`The artifact path could not be resolved: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (!isInsideDir(projectRoot, artifactAbs)) {
|
|
1064
|
+
error(`The artifact path resolves outside the project and was not read: ${boundedPath(artifactPath)}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
1068
|
+
const traceMod = _trace ?? require('./vault-trace.cjs');
|
|
1069
|
+
const content = vault.readHeadBounded(artifactAbs, TRACE_ARTIFACT_READ_BYTES);
|
|
1070
|
+
const parsed = traceMod.parseVaultTraceBlock(content, artifactPath);
|
|
1071
|
+
// THE LEDGER, AND ONLY THE LEDGER. This arm must never call the matcher.
|
|
1072
|
+
// Re-deriving the practice set at render time would produce a section
|
|
1073
|
+
// that disagrees with the frontmatter it is supposedly generated from,
|
|
1074
|
+
// which is the precise failure D-03 exists to prevent: the whole point of
|
|
1075
|
+
// GENERATING the surface is that the two can never disagree (T-03-24).
|
|
1076
|
+
// Adding a matcher call here looks like a robustness improvement and is
|
|
1077
|
+
// the exact opposite — it reintroduces the drift the generation removed.
|
|
1078
|
+
//
|
|
1079
|
+
// An absent ledger yields the parser's documented absent result and NO
|
|
1080
|
+
// section — not an error, and not an empty heading. An empty ledger is a
|
|
1081
|
+
// different thing entirely and the leaf module's renderer handles it: it
|
|
1082
|
+
// emits the heading plus one explanatory line, because a degraded run's
|
|
1083
|
+
// record must stay visible and a reader who finds no section at all
|
|
1084
|
+
// cannot tell a degraded run from a plan that was never vault-enabled.
|
|
1085
|
+
const rendered = parsed.present ? traceMod.renderPracticesApplied(parsed.statement) : '';
|
|
1086
|
+
if (raw) {
|
|
1087
|
+
c.output(parsed, true, JSON.stringify(parsed));
|
|
1088
|
+
}
|
|
1089
|
+
else {
|
|
1090
|
+
c.output(parsed, true, rendered);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
431
1093
|
}
|
|
432
1094
|
else if (subcommand === 'audit') {
|
|
433
|
-
|
|
1095
|
+
// ── The mechanical practice audit (Phase 05-06, CHECK-03/SC1) ──
|
|
1096
|
+
//
|
|
1097
|
+
// A PASS-THROUGH, on exactly the rule the two trace arms above state: it
|
|
1098
|
+
// collects arguments, calls through and emits. It holds NO verdict, NO
|
|
1099
|
+
// message, NO range and NO cap logic. `check vault.audit` serializes the
|
|
1100
|
+
// SAME envelope from the SAME engine, and duplicating any of it here would
|
|
1101
|
+
// create a second place where the operator's report can disagree with the
|
|
1102
|
+
// gate that blocks the build on it (D-05-02). Even the human text is the
|
|
1103
|
+
// leaf's — `renderAuditReport` is called over the object the leaf returned,
|
|
1104
|
+
// never over a copy this arm rebuilt.
|
|
1105
|
+
//
|
|
1106
|
+
// ORDERING, copied from the trace arm above because the reason carries over
|
|
1107
|
+
// verbatim: the capability gate is the FIRST branch, ahead of the flag
|
|
1108
|
+
// allowlist (the admission verdict follows it here, per QD-03 below). Both orders satisfy T-02-07/T-03-25 — nothing is required and
|
|
1109
|
+
// nothing spawns before an unknown flag is rejected, because the gate is a
|
|
1110
|
+
// bounded config read and not a subprocess. Only THIS order also satisfies
|
|
1111
|
+
// D-04's non-negotiable clause: an inert project's output is byte-identical
|
|
1112
|
+
// on every path INCLUDING a mistyped one (T-03-26).
|
|
1113
|
+
//
|
|
1114
|
+
// 16-09 (SEAL-02, D-39) — ONE exception, and it is the gate's own: a
|
|
1115
|
+
// switch that is off over a phase holding engine-written vault records.
|
|
1116
|
+
// `check vault.audit` answers that `disabled_with_artifacts`, blocking, and
|
|
1117
|
+
// this verb prints the SAME line from the SAME leaf (an agreeing pair). A
|
|
1118
|
+
// project that never recorded anything still prints nothing.
|
|
1119
|
+
if (!tracePracticesActive(cwd)) {
|
|
1120
|
+
const refusal = disabledWithArtifactsRefusal(cwd, args);
|
|
1121
|
+
if (refusal)
|
|
1122
|
+
c.output(refusal, true, raw ? JSON.stringify(refusal) : refusal.message);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
// Quick 261015-at3 (QD-03, D-39): the admission verdict comes AFTER the
|
|
1126
|
+
// switch block above, not before it. That block's refusal already asks
|
|
1127
|
+
// `hasPlatformBindings` first and is the verb's half of an agreeing pair with
|
|
1128
|
+
// `check vault.audit`; an admission-first line would silence the
|
|
1129
|
+
// `vault.enabled: false`-over-records refusal the gate still answers. This
|
|
1130
|
+
// line closes the remaining shape — both switches on, no bindings — where
|
|
1131
|
+
// `check vault.audit` answers `inert`.
|
|
1132
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1133
|
+
const owner = require('./vault.cjs');
|
|
1134
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
1135
|
+
return;
|
|
1136
|
+
// T-5-19 / T-02-07: whole-token rejection, so `--raw=json` is caught by the
|
|
1137
|
+
// same check as a bare unknown flag rather than slipping past a prefix test
|
|
1138
|
+
// and reaching a subprocess. The host (pp-core/bin/pp-tools.cjs) splices out
|
|
1139
|
+
// its own global flags before dispatch, which is why the recognized set is
|
|
1140
|
+
// one wide.
|
|
1141
|
+
// The flag's NAME is spelled here and in `check-command-router.cts`'s
|
|
1142
|
+
// `AUDIT_RANGE_FLAG`. What must not be duplicated is the POLICY — what
|
|
1143
|
+
// counts as a well-formed range — and it is not: both surfaces call the
|
|
1144
|
+
// leaf's `isValidOperatorRange`. A shared constant for the token itself
|
|
1145
|
+
// would mean importing the audit leaf at this file's top level, which the
|
|
1146
|
+
// lazy-require convention here exists to avoid, to buy agreement on seven
|
|
1147
|
+
// characters that a test on each surface already pins.
|
|
1148
|
+
const AUDIT_RANGE_FLAG = '--range';
|
|
1149
|
+
const RECOGNIZED_AUDIT_FLAGS = new Set(['--raw', AUDIT_RANGE_FLAG]);
|
|
1150
|
+
const unrecognizedAuditFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_AUDIT_FLAGS.has(a));
|
|
1151
|
+
if (unrecognizedAuditFlag !== undefined) {
|
|
1152
|
+
error(`Unrecognized flag "${unrecognizedAuditFlag}" for vault audit. ` +
|
|
1153
|
+
`Supported: a positional phase directory, --raw, ${AUDIT_RANGE_FLAG} <from>..<to>.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
// 13-07 (D-13, PARK-02): standards mode has no practice layer to audit.
|
|
1157
|
+
// AFTER the inert gate above (D-22) and the flag guard, BEFORE the range,
|
|
1158
|
+
// the positional and the leaf — the same place `check vault.audit` answers
|
|
1159
|
+
// it, so the two surfaces agree on every argument shape. Still a
|
|
1160
|
+
// pass-through: it emits the leaf's builder output, never its own wording
|
|
1161
|
+
// (D-05-02).
|
|
1162
|
+
if (modeSeam.isStandardsModeActive(cwd)) {
|
|
1163
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1164
|
+
const { notApplicableGateEnvelope } = require('./vault-mode.cjs');
|
|
1165
|
+
const naAudit = notApplicableGateEnvelope('vault.audit');
|
|
1166
|
+
c.output(naAudit, true, raw ? JSON.stringify(naAudit) : naAudit.message);
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
// 08-06 (D-08-19): the operator's range, and the ONE token in this arm that
|
|
1170
|
+
// is a flag's VALUE rather than a positional. It is located before the
|
|
1171
|
+
// positional is picked because the pick below is "the first token that does
|
|
1172
|
+
// not start with `--`" — which, for `vault audit --range a..b <dir>`, is
|
|
1173
|
+
// `a..b`. That would have scoped the audit to a phase directory nobody
|
|
1174
|
+
// named, silently and with a plausible-looking envelope: the exact class of
|
|
1175
|
+
// the WR-08 defect the gate surface's positional scanner was rewritten to
|
|
1176
|
+
// remove. Here the fix is local, because this arm has one value-bearing
|
|
1177
|
+
// flag and no enforcer appending others.
|
|
1178
|
+
const auditArgs = args.slice(2);
|
|
1179
|
+
const rangeAt = auditArgs.indexOf(AUDIT_RANGE_FLAG);
|
|
1180
|
+
const auditRangeValue = rangeAt === -1 ? undefined : auditArgs[rangeAt + 1];
|
|
1181
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
1182
|
+
const auditMod = _audit ?? require('./vault-audit.cjs');
|
|
1183
|
+
if (rangeAt !== -1) {
|
|
1184
|
+
// HARVESTED, NEVER RESTATED: the predicate is the leaf's own, so this
|
|
1185
|
+
// operator surface and the gate surface cannot disagree about what a
|
|
1186
|
+
// well-formed range is. A seam that does not provide it (the test mock)
|
|
1187
|
+
// falls back to the real module rather than to "anything goes" — a
|
|
1188
|
+
// missing predicate must never mean a passing one.
|
|
1189
|
+
//
|
|
1190
|
+
// Each candidate is CALLED ON ITS OWN OBJECT rather than detached into a
|
|
1191
|
+
// variable: an unbound method is a `this` hazard the linter is right
|
|
1192
|
+
// about, and nothing here needs the function as a value.
|
|
1193
|
+
const rangeIsWellFormed = (v) => {
|
|
1194
|
+
if (typeof auditMod.isValidOperatorRange === 'function') {
|
|
1195
|
+
return auditMod.isValidOperatorRange(v);
|
|
1196
|
+
}
|
|
1197
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1198
|
+
const leaf = require('./vault-audit.cjs');
|
|
1199
|
+
return typeof leaf.isValidOperatorRange === 'function' ? leaf.isValidOperatorRange(v) : false;
|
|
1200
|
+
};
|
|
1201
|
+
if (typeof auditRangeValue !== 'string' || !rangeIsWellFormed(auditRangeValue)) {
|
|
1202
|
+
// The refused value is NOT echoed: it failed validation, so it is not
|
|
1203
|
+
// trusted text, and this arm's message is read by a person whose next
|
|
1204
|
+
// action is to retype it (T-08-32).
|
|
1205
|
+
error(`${AUDIT_RANGE_FLAG} needs a well-formed <from>..<to> git range (letters, digits, `
|
|
1206
|
+
+ '. _ / ^ ~ -, two or three dots, at most 200 characters). The supplied value is not '
|
|
1207
|
+
+ 'reproduced here because it failed validation. Re-run with a well-formed range, or '
|
|
1208
|
+
+ 'drop the flag to let the phase-start ladder resolve it.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// The phase argument. A DIRECTORY path carries the workstream partition,
|
|
1213
|
+
// which is why neither dispatch point has to pass `--ws`
|
|
1214
|
+
// (check-query-gate.md:27, and the four-times-shipped omitted-`--ws`
|
|
1215
|
+
// lesson behind it). The range's VALUE is excluded by index, so the flag
|
|
1216
|
+
// may precede the positional.
|
|
1217
|
+
const auditPhaseArg = auditArgs.find((a, i) => !a.startsWith('--') && !(rangeAt !== -1 && i === rangeAt + 1));
|
|
1218
|
+
if (auditPhaseArg === undefined) {
|
|
1219
|
+
error(`No phase directory was given. Usage: vault audit <phase-dir> [${AUDIT_RANGE_FLAG} <from>..<to>] [--raw].`, ERROR_REASON.SDK_MISSING_ARG);
|
|
1220
|
+
return;
|
|
1221
|
+
}
|
|
1222
|
+
let auditRoot;
|
|
1223
|
+
try {
|
|
1224
|
+
auditRoot = findProjectRoot(cwd) || cwd;
|
|
1225
|
+
}
|
|
1226
|
+
catch {
|
|
1227
|
+
auditRoot = cwd;
|
|
1228
|
+
}
|
|
1229
|
+
// 08-10 (F4). THE ARGUMENT IS PART OF THE PASS-THROUGH. This arm read the
|
|
1230
|
+
// positional as a raw path while `check vault.audit` resolved it through
|
|
1231
|
+
// `resolveTracePhaseDir` — either an existing directory OR a phase token the
|
|
1232
|
+
// locator resolves. Measured on the live fixture, `01` therefore produced
|
|
1233
|
+
// `{"status":"ok","block":false,"rangeSource":"phase-start-commit"}` from the
|
|
1234
|
+
// gate and `{"status":"warn","block":true,"rangeSource":"base-branch",
|
|
1235
|
+
// "...":"manifest-not-found"}` from the operator's own command: a blocking,
|
|
1236
|
+
// meaningless verdict where the gate saw a clean phase-scoped pass. That is
|
|
1237
|
+
// exactly the disagreement this arm's own doc comment above forbids
|
|
1238
|
+
// (D-05-02), so the RESOLVER is harvested — never restated — from the leaf
|
|
1239
|
+
// both routers already import.
|
|
1240
|
+
//
|
|
1241
|
+
// From the REAL leaf, not the `_vault` seam — same rule and same reason as
|
|
1242
|
+
// the `isInsideDir` harvest in the render arm above: a containment decision
|
|
1243
|
+
// must not be switchable by a test mock.
|
|
1244
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1245
|
+
const { resolveTracePhaseDir } = require('./vault.cjs');
|
|
1246
|
+
const resolvedAuditPhase = resolveTracePhaseDir(auditRoot, cwd, auditPhaseArg);
|
|
1247
|
+
if (!resolvedAuditPhase) {
|
|
1248
|
+
// The SAME `unresolved` shape the gate emits (check-command-router.cts),
|
|
1249
|
+
// not a base-branch fallback that reads as a real audit of nothing. Stated
|
|
1250
|
+
// only from what was measured, and naming a re-run rather than a deletion
|
|
1251
|
+
// (T-4-75).
|
|
1252
|
+
const unresolved = {
|
|
1253
|
+
status: 'unresolved',
|
|
1254
|
+
block: true,
|
|
1255
|
+
message: `Vault audit: "${boundedPath(auditPhaseArg)}" is neither an existing `
|
|
1256
|
+
+ 'directory inside the project nor a phase the locator resolves, so no practice was '
|
|
1257
|
+
+ 'compared against anything. Repair: re-run this command with the phase directory.',
|
|
1258
|
+
};
|
|
1259
|
+
c.output(unresolved, true, raw ? JSON.stringify(unresolved) : unresolved.message);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const auditResult = auditMod.runVaultAudit({
|
|
1263
|
+
root: auditRoot,
|
|
1264
|
+
phaseDir: resolvedAuditPhase.abs,
|
|
1265
|
+
exec: _exec ?? shell_command_projection_cjs_1.execGitLocal,
|
|
1266
|
+
// Spread, never `range: undefined` — the leaf keys on the key's TYPE to
|
|
1267
|
+
// decide whether its ladder runs.
|
|
1268
|
+
...(typeof auditRangeValue === 'string' ? { range: auditRangeValue } : {}),
|
|
1269
|
+
});
|
|
1270
|
+
if (raw) {
|
|
1271
|
+
c.output(auditResult, true, JSON.stringify(auditResult));
|
|
1272
|
+
}
|
|
1273
|
+
else {
|
|
1274
|
+
c.output(auditResult, true, auditMod.renderAuditReport(auditResult));
|
|
1275
|
+
}
|
|
434
1276
|
}
|
|
435
|
-
else if (subcommand === '
|
|
436
|
-
|
|
1277
|
+
else if (subcommand === 'manifest') {
|
|
1278
|
+
// ── The phase-manifest writer (260915-ik2, UAT-6-F1) ──
|
|
1279
|
+
//
|
|
1280
|
+
// `start` anchors `phases[NN].startCommit = HEAD` (write-once); `record`
|
|
1281
|
+
// writes `phases[NN].commits` from that anchor. Both are the leaf's —
|
|
1282
|
+
// this arm collects arguments, calls through and emits, exactly as the
|
|
1283
|
+
// `audit` arm above does, and in the same ORDER for the same reason: the
|
|
1284
|
+
// capability gate is FIRST and silent, so an inert project's output is
|
|
1285
|
+
// byte-identical on every path including a mistyped one (T-03-26,
|
|
1286
|
+
// T-ik2-08). It exits 0 on every path: the step files print the result and
|
|
1287
|
+
// continue, and a refusal travels as `ok: false` with a reason.
|
|
1288
|
+
//
|
|
1289
|
+
// Quick 261015-at3: the admission verdict precedes the capability gate — a
|
|
1290
|
+
// project with no `.platform-bindings.json` gets no manifest file written.
|
|
1291
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1292
|
+
const owner = require('./vault.cjs');
|
|
1293
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
1294
|
+
return;
|
|
1295
|
+
if (!tracePracticesActive(cwd))
|
|
1296
|
+
return;
|
|
1297
|
+
const RECOGNIZED_MANIFEST_FLAGS = new Set(['--raw']);
|
|
1298
|
+
const unrecognizedManifestFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_MANIFEST_FLAGS.has(a));
|
|
1299
|
+
if (unrecognizedManifestFlag !== undefined) {
|
|
1300
|
+
error(`Unrecognized flag "${unrecognizedManifestFlag}" for vault manifest. ` +
|
|
1301
|
+
`Supported: a verb (${MANIFEST_VERBS.join(' | ')}), a positional phase directory, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
const manifestPositionals = args.slice(2).filter((a) => !a.startsWith('--'));
|
|
1305
|
+
const manifestVerb = manifestPositionals[0];
|
|
1306
|
+
if (manifestVerb !== 'start' && manifestVerb !== 'record') {
|
|
1307
|
+
error(`Unknown vault manifest verb. Available: ${MANIFEST_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
// Quick 261015-ect (QD-15): parked in standards mode. The range this verb
|
|
1311
|
+
// anchors serves two gates, `vault audit` and `check vault.review`, and
|
|
1312
|
+
// both answer not_applicable in this mode (PARK-02, PARK-04). Its third
|
|
1313
|
+
// reader, `/pp-undo --phase` (pp-core/workflows/undo.md), falls back to its
|
|
1314
|
+
// git-log search when the file or the phase entry is missing — the path a
|
|
1315
|
+
// project with no bindings or with the practices switch off already takes.
|
|
1316
|
+
// After the verb check and before any positional or phase read; the
|
|
1317
|
+
// envelope carries `ok: false` and a typed reason, which is how the step
|
|
1318
|
+
// files read a manifest refusal (print the line and continue).
|
|
1319
|
+
if (modeSeam.isStandardsModeActive(cwd)) {
|
|
1320
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1321
|
+
const { notApplicableManifestVerbEnvelope } = require('./vault-mode.cjs');
|
|
1322
|
+
const naManifest = notApplicableManifestVerbEnvelope();
|
|
1323
|
+
c.output(naManifest, true, raw ? JSON.stringify(naManifest) : naManifest.message);
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
// The phase DIRECTORY, as for `audit`: the path carries the workstream
|
|
1327
|
+
// partition, and the manifest is written two levels above it.
|
|
1328
|
+
const manifestPhaseDir = manifestPositionals[1];
|
|
1329
|
+
if (manifestPhaseDir === undefined) {
|
|
1330
|
+
error('No phase directory was given. Usage: vault manifest <start|record> <phase-dir> [--raw].', ERROR_REASON.SDK_MISSING_ARG);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
let manifestRoot;
|
|
1334
|
+
try {
|
|
1335
|
+
manifestRoot = findProjectRoot(cwd) || cwd;
|
|
1336
|
+
}
|
|
1337
|
+
catch {
|
|
1338
|
+
manifestRoot = cwd;
|
|
1339
|
+
}
|
|
1340
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
1341
|
+
const manifestMod = _audit ?? require('./vault-audit.cjs');
|
|
1342
|
+
const writeOpts = { root: manifestRoot, phaseDir: manifestPhaseDir, cwd, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal };
|
|
1343
|
+
const manifestResult = manifestVerb === 'start'
|
|
1344
|
+
? manifestMod.recordPhaseStart(writeOpts)
|
|
1345
|
+
: manifestMod.recordPhaseCommits(writeOpts);
|
|
1346
|
+
if (raw) {
|
|
1347
|
+
c.output(manifestResult, true, JSON.stringify(manifestResult));
|
|
1348
|
+
}
|
|
1349
|
+
else {
|
|
1350
|
+
c.output(manifestResult, true, manifestMod.renderManifestResult(manifestResult));
|
|
1351
|
+
}
|
|
437
1352
|
}
|
|
438
1353
|
else if (subcommand === 'update') {
|
|
439
|
-
// `vault update [<tag>] [--yes]
|
|
440
|
-
// writes nothing; see src/vault-update.cts for the contract.
|
|
1354
|
+
// PBI 74316: `vault update [<tag>] [--yes]`. Without --yes it reports the
|
|
1355
|
+
// plan and writes nothing; see src/vault-update.cts for the contract.
|
|
441
1356
|
const RECOGNIZED_UPDATE_FLAGS = new Set(['--raw', '--yes']);
|
|
442
1357
|
const unrecognizedUpdateFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_UPDATE_FLAGS.has(a));
|
|
443
1358
|
if (unrecognizedUpdateFlag !== undefined) {
|
|
444
|
-
error(`Unrecognized flag "${
|
|
1359
|
+
error(`Unrecognized flag "${unrecognizedUpdateFlag}" for vault update. Supported: --yes, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
445
1360
|
return;
|
|
446
1361
|
}
|
|
447
1362
|
const updatePositionals = args.slice(2).filter((a) => !a.startsWith('--'));
|
|
@@ -466,109 +1381,282 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
466
1381
|
c.output(updateResult, true, updateMod.renderVaultUpdate(updateResult));
|
|
467
1382
|
}
|
|
468
1383
|
}
|
|
1384
|
+
else if (subcommand === 'standards') {
|
|
1385
|
+
// 15-07 (D-06, D-08, D-13): the standards record verbs. The arm body lives
|
|
1386
|
+
// in `routeVaultStandards` below; the invalid-mode refusal above already
|
|
1387
|
+
// ran, because 'standards' is a member of VAULT_SUBCOMMANDS.
|
|
1388
|
+
routeVaultStandards({ cwd, args, raw, error, vault, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
|
|
1389
|
+
}
|
|
1390
|
+
else if (subcommand === 'admission') {
|
|
1391
|
+
// 16-04 (D-28, D-05): the arm body lives in `admissionVerb` below.
|
|
1392
|
+
admissionVerb({ cwd, rest: args.slice(2), raw, error, c });
|
|
1393
|
+
}
|
|
1394
|
+
else if (subcommand === 'anchor') {
|
|
1395
|
+
// 16-06 (D-31, D-32): the arm body lives in `routeVaultAnchor` below.
|
|
1396
|
+
routeVaultAnchor({ cwd, args, raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
|
|
1397
|
+
}
|
|
1398
|
+
else if (subcommand === 'door-record') {
|
|
1399
|
+
// 16-06 (D-12): the arm body lives in `doorRecordVerb` below.
|
|
1400
|
+
doorRecordVerb({ cwd, rest: args.slice(2), raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
|
|
1401
|
+
}
|
|
469
1402
|
else {
|
|
470
|
-
error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.
|
|
1403
|
+
error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
471
1404
|
}
|
|
472
1405
|
}
|
|
473
1406
|
/**
|
|
474
|
-
* `vault
|
|
1407
|
+
* `vault admission [--explain]` (16-04, D-28, D-05): prints exactly one of
|
|
1408
|
+
* `inert | off | on | config-unreadable` and exits 0. With `--explain` and
|
|
1409
|
+
* `config-unreadable`, one more line per unreadable file names the file and the
|
|
1410
|
+
* repair; `--raw` prints the verdict object instead.
|
|
475
1411
|
*
|
|
476
|
-
* The
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
1412
|
+
* The answer comes from `vaultAdmissionVerdict` in the REAL src/vault.cts, never
|
|
1413
|
+
* from the injectable `_vault` seam: a governance decision must not be
|
|
1414
|
+
* switchable by a test mock (the mode-guard precedent in `routeVaultCommand`).
|
|
1415
|
+
* It stays on the cwd's own verdict, not the producer verdict the seven record
|
|
1416
|
+
* producers ask (quick 261015-ect, QD-10): this verb is a REPORT of the cwd,
|
|
1417
|
+
* which is what every fence asks, so a bindings-less linked worktree answers
|
|
1418
|
+
* `inert` here even where its owning checkout is admitted (QD-09 "Not changed").
|
|
480
1419
|
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
1420
|
+
* Exit 0 on every answer, because a door fence reads the TOKEN: any stdout other
|
|
1421
|
+
* than `on`, `off` or `inert` halts it, and its `|| echo unrecognised` fallback
|
|
1422
|
+
* turns a non-zero exit into a halt as well. The only non-zero paths are an
|
|
1423
|
+
* argument error here and the 13-06 invalid-mode refusal that precedes every
|
|
1424
|
+
* vault subcommand where `.platform-bindings.json` exists (QD-11); both
|
|
1425
|
+
* therefore halt the fence, never skip it.
|
|
486
1426
|
*/
|
|
487
|
-
function
|
|
1427
|
+
function admissionVerb({ cwd, rest, raw, error, c }) {
|
|
1428
|
+
let explain = false;
|
|
1429
|
+
for (const a of rest) {
|
|
1430
|
+
if (a === '--raw')
|
|
1431
|
+
continue;
|
|
1432
|
+
if (a === '--explain') {
|
|
1433
|
+
explain = true;
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
error(`Unrecognized argument "${boundedPath(a)}" for vault admission. Supported: --explain, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
488
1439
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
if (verb !== 'scaffold' && verb !== 'render') {
|
|
494
|
-
error(`Unknown trace subcommand. Available: ${TRACE_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1440
|
+
const owner = require('./vault.cjs');
|
|
1441
|
+
const verdict = owner.vaultAdmissionVerdict(cwd);
|
|
1442
|
+
if (raw) {
|
|
1443
|
+
c.output(verdict, true, JSON.stringify(verdict));
|
|
495
1444
|
return;
|
|
496
1445
|
}
|
|
497
|
-
|
|
498
|
-
if (
|
|
1446
|
+
const lines = [verdict.state];
|
|
1447
|
+
if (explain && verdict.state === 'config-unreadable') {
|
|
1448
|
+
for (const u of verdict.unreadable) {
|
|
1449
|
+
lines.push(`${u.file}: ${u.error} — repair: fix the JSON in ${u.file} (or remove the key that broke it) and re-run; ` +
|
|
1450
|
+
'the gate cannot read this file and refuses to pass having checked nothing');
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
c.output(verdict, true, `${lines.join('\n')}\n`);
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* `vault standards block|consulted` (15-07). The CLI owns everything provable
|
|
1457
|
+
* about a standards record (D-06): the pin comes from the manifest, a path is
|
|
1458
|
+
* accepted only when the loaded INDEX resolves it (D-19), the degraded line is
|
|
1459
|
+
* chosen from the measured state (D-08) and the override line comes from the
|
|
1460
|
+
* one override builder. The planner fills reason slots and the executor fills
|
|
1461
|
+
* status slots; nothing else in the record is typed by an agent.
|
|
1462
|
+
*
|
|
1463
|
+
* ORDER: unless `vault admission` answers `on` (bindings present, every config
|
|
1464
|
+
* readable, the vault switch on) both verbs print nothing and write nothing,
|
|
1465
|
+
* before any argument or the mode is read — so a project without the vault is
|
|
1466
|
+
* byte-identical on every path including a mistyped one, and a standards record
|
|
1467
|
+
* cannot be produced on a project whose trace gate is inert (17-REVIEW CR-01,
|
|
1468
|
+
* D-13; the same gate `routeVaultAnchor` and `doorRecordVerb` ask). The verdict
|
|
1469
|
+
* is asked of the checkout whose vault cache the verbs read (quick 261015-at3,
|
|
1470
|
+
* QD-09): in a linked worktree reading its owning checkout's cache — where an
|
|
1471
|
+
* executor runs `consulted` and has no untracked bindings of its own — that is
|
|
1472
|
+
* the owning checkout, which is also where the gate reading the record runs. Then
|
|
1473
|
+
* practices mode answers its one not-applicable line, then the verb and the
|
|
1474
|
+
* flags are checked. An invalid mode never reaches here: the pre-dispatch
|
|
1475
|
+
* guard in `routeVaultCommand` refused it.
|
|
1476
|
+
*
|
|
1477
|
+
* Every governance read is from the REAL module, never the `_vault` seam (the
|
|
1478
|
+
* mode-guard precedent); only the bounded PLAN read goes through the seam, so
|
|
1479
|
+
* a test can count it. Neither verb writes a file.
|
|
1480
|
+
*/
|
|
1481
|
+
function routeVaultStandards({ cwd, args, raw, error, vault, c, exec }) {
|
|
1482
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1483
|
+
const owner = require('./vault.cjs');
|
|
1484
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
499
1485
|
return;
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
1486
|
+
if (!owner.isStandardsModeActive(cwd)) {
|
|
1487
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1488
|
+
const { notApplicableStandardsVerbEnvelope } = require('./vault-mode.cjs');
|
|
1489
|
+
const na = notApplicableStandardsVerbEnvelope();
|
|
1490
|
+
c.output(na, true, raw ? JSON.stringify(na) : na.message);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
const verb = args[2];
|
|
1494
|
+
const rest = args.slice(3);
|
|
1495
|
+
if (verb === 'block') {
|
|
1496
|
+
standardsBlockVerb({ cwd, rest, raw, error, c, owner });
|
|
1497
|
+
}
|
|
1498
|
+
else if (verb === 'consulted') {
|
|
1499
|
+
standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec });
|
|
1500
|
+
}
|
|
1501
|
+
else {
|
|
1502
|
+
error(`Unknown standards subcommand. Available: ${STANDARDS_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function standardsBlockVerb({ cwd, rest, raw, error, c, owner }) {
|
|
1506
|
+
// ── arguments ──
|
|
1507
|
+
let planId;
|
|
1508
|
+
let planGiven = false;
|
|
1509
|
+
const positionals = [];
|
|
1510
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1511
|
+
const a = rest[i];
|
|
1512
|
+
if (a === '--raw')
|
|
1513
|
+
continue;
|
|
1514
|
+
if (a === '--plan') {
|
|
1515
|
+
planGiven = true;
|
|
1516
|
+
planId = rest[i + 1];
|
|
1517
|
+
i += 1;
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
if (a.startsWith('--plan=')) {
|
|
1521
|
+
planGiven = true;
|
|
1522
|
+
planId = a.slice('--plan='.length);
|
|
1523
|
+
continue;
|
|
507
1524
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (stage === undefined || !TRACE_STAGES.includes(stage)) {
|
|
512
|
-
error(`Missing or unrecognized --stage value for vault trace scaffold. Supported: ${TRACE_STAGES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1525
|
+
if (a.startsWith('--')) {
|
|
1526
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault standards block. ` +
|
|
1527
|
+
'Supported: --plan <plan-id>, positional INDEX paths, --raw.', ERROR_REASON.SDK_FAIL_FAST);
|
|
513
1528
|
return;
|
|
514
1529
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1530
|
+
positionals.push(a);
|
|
1531
|
+
}
|
|
1532
|
+
if (!planGiven || planId === undefined || planId === '' || planId.startsWith('--')) {
|
|
1533
|
+
error('No plan identifier was given. Usage: vault standards block --plan <plan-id> [<index-path>...] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
|
|
1537
|
+
error(`Invalid --plan value "${boundedPath(planId)}" for vault standards block. ${SCAFFOLD_PLAN_ID_FORM} ${SCAFFOLD_QUICK_ID_FORM}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
// ── the measured state, read once, from one cache ──
|
|
1541
|
+
// The manifest and the INDEX come from the same directory, the one
|
|
1542
|
+
// `resolveVaultCacheDir` names (in a main checkout, `.planning/vault/`), so
|
|
1543
|
+
// the pin and the resolved paths always describe one cache. "Loaded" and the
|
|
1544
|
+
// stamp status are the leaf's predicates, shared with the trace gate.
|
|
1545
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1546
|
+
const indexLeaf = require('./vault-index.cjs');
|
|
1547
|
+
const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
|
|
1548
|
+
const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
|
|
1549
|
+
const state = pin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cacheDir });
|
|
1550
|
+
const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(pin, state);
|
|
1551
|
+
// ── the selection (D-19) ──
|
|
1552
|
+
// The repair comes from the one owner the consulted verb and the trace gate
|
|
1553
|
+
// also read (15-REVIEW iter-3 WR-01), so the three cannot name different ones.
|
|
1554
|
+
const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
|
|
1555
|
+
if (indexFix !== null && positionals.length > 0) {
|
|
1556
|
+
error(`Vault standards block: no document can be selected: the index is ${indexStatus}. ` +
|
|
1557
|
+
'Run the command with no index paths to record that state; to select documents, ' +
|
|
1558
|
+
`${indexFix.repair.charAt(0).toLowerCase()}${indexFix.repair.slice(1)} first.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
const selectable = vaultStandardsLeaf.standardsResolvedPaths(state);
|
|
1562
|
+
const seen = new Set();
|
|
1563
|
+
for (const p of positionals) {
|
|
1564
|
+
// Exact string membership: no normalisation, no join, no stat (Phase 14
|
|
1565
|
+
// D-08). A path the INDEX does not resolve is refused in ONE wording
|
|
1566
|
+
// whatever the reason, so the refusal never tells a caller which class of
|
|
1567
|
+
// file a path names.
|
|
1568
|
+
if (seen.has(p)) {
|
|
1569
|
+
error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is listed more than once; name each INDEX path once.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
520
1570
|
return;
|
|
521
1571
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
1572
|
+
seen.add(p);
|
|
1573
|
+
if (!selectable.has(p)) {
|
|
1574
|
+
error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is not a selectable INDEX entry. ` +
|
|
1575
|
+
'Choose from the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, exactly as written there.', ERROR_REASON.SDK_FAIL_FAST);
|
|
525
1576
|
return;
|
|
526
1577
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
1578
|
+
}
|
|
1579
|
+
// ── the record ──
|
|
1580
|
+
const repoText = pin !== null
|
|
1581
|
+
? indexLeaf.sanitiseVaultText(owner.redactCloneTarget(pin.repo) ?? '', 200).replace(/\s+/g, '_')
|
|
1582
|
+
: '';
|
|
1583
|
+
// Document lines only on a healthy index with a selection; otherwise the
|
|
1584
|
+
// one degraded line the measured status implies (D-08).
|
|
1585
|
+
const degraded = indexStatus === 'ok' && positionals.length > 0
|
|
1586
|
+
? null
|
|
1587
|
+
: vaultStandardsLeaf.standardsDegradedLineFor(indexStatus);
|
|
1588
|
+
const record = {
|
|
1589
|
+
planId,
|
|
1590
|
+
repo: pin !== null && repoText !== '' ? repoText : null,
|
|
1591
|
+
ref: pin?.ref ?? null,
|
|
1592
|
+
sha: pin?.sha ?? null,
|
|
1593
|
+
indexStatus,
|
|
1594
|
+
documents: degraded === null
|
|
1595
|
+
? positionals.map((p) => ({ path: p, ref: pin.ref, sha: pin.sha, reason: '' }))
|
|
1596
|
+
: [],
|
|
1597
|
+
degraded,
|
|
1598
|
+
override: null,
|
|
1599
|
+
};
|
|
1600
|
+
// ── the override line (D-08): only through the one builder ──
|
|
1601
|
+
// Recorded only when the load gate's verdict GENUINELY blocks now and the
|
|
1602
|
+
// trace leaf accepts the operator's override reason from this process's
|
|
1603
|
+
// environment; a non-blocking verdict records nothing (D-15 of Phase 4). The
|
|
1604
|
+
// router names no part of the override stack (the leaf owns it all). The
|
|
1605
|
+
// gate name is the scaffold arm's own constant. The verdict is computed
|
|
1606
|
+
// against the SAME cache the stamp above measured (D-36, 15-REVIEW iter-2
|
|
1607
|
+
// WR-02), exactly as the trace gate recomputes it, so the suppressed reason
|
|
1608
|
+
// recorded here is the one the gate will compare it with, in any checkout.
|
|
1609
|
+
const verdict = owner.computeVaultVerdict(cwd, { vaultDir: cacheDir });
|
|
1610
|
+
if (verdict.block === true) {
|
|
1611
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1612
|
+
const traceLeaf = require('./vault-trace.cjs');
|
|
1613
|
+
const ov = traceLeaf.buildStandardsOverrideRecord(cwd, {
|
|
1614
|
+
gate: TRACE_OVERRIDE_GATE,
|
|
1615
|
+
block: true,
|
|
1616
|
+
suppressedReason: verdict.reason,
|
|
539
1617
|
});
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
// No files is a REAL path: a documentation-only plan is still governed by
|
|
543
|
-
// the global practices, and its ruled-out set must stay auditable.
|
|
544
|
-
const statement = traceMod.buildTraceStatement({ cwd, stage, plan: planId, files: fileInputs });
|
|
545
|
-
if (raw) {
|
|
546
|
-
c.output(statement, true, JSON.stringify(statement));
|
|
547
|
-
}
|
|
548
|
-
else {
|
|
549
|
-
c.output(statement, true, traceMod.serializeTraceBlock(statement));
|
|
1618
|
+
if (ov) {
|
|
1619
|
+
record.override = { gate: ov.gate, suppressedReason: ov.suppressedReason, who: ov.who, when: ov.when, reason: ov.reason };
|
|
550
1620
|
}
|
|
1621
|
+
}
|
|
1622
|
+
let text;
|
|
1623
|
+
try {
|
|
1624
|
+
text = vaultStandardsLeaf.serializeStandardsBlock(record);
|
|
1625
|
+
}
|
|
1626
|
+
catch (e) {
|
|
1627
|
+
error(`Vault standards block could not write the record: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
|
|
551
1628
|
return;
|
|
552
1629
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
1630
|
+
const envelope = owner.withVaultMode({
|
|
1631
|
+
status: 'ok',
|
|
1632
|
+
planId,
|
|
1633
|
+
stamp: { repo: record.repo, ref: record.ref, sha: record.sha, indexStatus },
|
|
1634
|
+
documents: record.documents.map((d) => ({ path: d.path, ref: d.ref, sha: d.sha })),
|
|
1635
|
+
degraded: record.degraded,
|
|
1636
|
+
override: record.override,
|
|
1637
|
+
text,
|
|
1638
|
+
}, cwd);
|
|
1639
|
+
c.output(envelope, true, raw ? JSON.stringify(envelope) : text);
|
|
1640
|
+
}
|
|
1641
|
+
function standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec, }) {
|
|
1642
|
+
const unrecognized = rest.find((a) => a.startsWith('--') && a !== '--raw');
|
|
1643
|
+
if (unrecognized !== undefined) {
|
|
1644
|
+
error(`Unrecognized flag "${boundedPath(unrecognized)}" for vault standards consulted. ` +
|
|
1645
|
+
'Supported: a positional plan path, --raw.', ERROR_REASON.SDK_FAIL_FAST);
|
|
558
1646
|
return;
|
|
559
1647
|
}
|
|
560
|
-
const
|
|
561
|
-
if (
|
|
562
|
-
error('
|
|
1648
|
+
const positionals = rest.filter((a) => !a.startsWith('--'));
|
|
1649
|
+
if (positionals.length !== 1) {
|
|
1650
|
+
error('vault standards consulted takes exactly one plan path. Usage: vault standards consulted <plan-path> [--raw].', positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
|
|
563
1651
|
return;
|
|
564
1652
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
// the
|
|
1653
|
+
const artifactPath = positionals[0];
|
|
1654
|
+
const shown = boundedPath(artifactPath);
|
|
1655
|
+
// CONTAINMENT FIRST, then the size, then the read (the `trace render`
|
|
1656
|
+
// precedent, T-03-50): the plan path is an unvalidated positional, and a
|
|
1657
|
+
// decision taken on the bounded read's RESULT would be no decision.
|
|
570
1658
|
if (artifactPath.indexOf('\u0000') !== -1) {
|
|
571
|
-
error(`The
|
|
1659
|
+
error(`The plan path is not a usable path: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
572
1660
|
return;
|
|
573
1661
|
}
|
|
574
1662
|
let projectRoot;
|
|
@@ -579,225 +1667,1374 @@ function routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace, }) {
|
|
|
579
1667
|
projectRoot = cwd;
|
|
580
1668
|
}
|
|
581
1669
|
const artifactAbs = node_path_1.default.resolve(cwd, artifactPath);
|
|
582
|
-
if (!
|
|
583
|
-
error(`The
|
|
1670
|
+
if (!owner.isInsideDir(projectRoot, artifactAbs)) {
|
|
1671
|
+
error(`The plan path resolves outside the project and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
584
1672
|
return;
|
|
585
1673
|
}
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// rendered section cannot disagree with the frontmatter it is generated
|
|
593
|
-
// from. An absent ledger yields no section; an empty one yields the heading
|
|
594
|
-
// plus one explanatory line (the leaf's renderer).
|
|
595
|
-
const rendered = parsed.present ? traceMod.renderPracticesApplied(parsed.statement) : '';
|
|
596
|
-
if (raw) {
|
|
597
|
-
c.output(parsed, true, JSON.stringify(parsed));
|
|
1674
|
+
let size;
|
|
1675
|
+
try {
|
|
1676
|
+
const st = node_fs_1.default.statSync(artifactAbs);
|
|
1677
|
+
if (!st.isFile())
|
|
1678
|
+
throw new Error('not a file');
|
|
1679
|
+
size = st.size;
|
|
598
1680
|
}
|
|
599
|
-
|
|
600
|
-
|
|
1681
|
+
catch {
|
|
1682
|
+
error(`The plan path is not a readable file: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1683
|
+
return;
|
|
601
1684
|
}
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
* the mechanical practice audit.
|
|
606
|
-
*
|
|
607
|
-
* A PASS-THROUGH: it collects arguments, calls `runVaultAudit` and prints the
|
|
608
|
-
* leaf's own envelope (`--raw`) or the leaf's own report. `check vault.audit`
|
|
609
|
-
* serializes the same envelope from the same engine, and the phase argument is
|
|
610
|
-
* resolved by the same `resolveDecisionPhaseDir`, so the report cannot disagree
|
|
611
|
-
* with the gate that blocks on it.
|
|
612
|
-
*
|
|
613
|
-
* Inert (no output at all) unless both vault keys are on, decided before the
|
|
614
|
-
* flag allowlist so an inert project is silent on every path. A malformed
|
|
615
|
-
* `--range` is refused by the leaf's own predicate and never echoed. The
|
|
616
|
-
* range's value is excluded by index, so the flag may precede the phase.
|
|
617
|
-
*/
|
|
618
|
-
function routeAudit({ args, cwd, raw, error, vault, c, _exec, _audit, }) {
|
|
619
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
620
|
-
const realVault = require('./vault.cjs');
|
|
621
|
-
const cfgGet = vault.cfgGet ? vault.cfgGet.bind(vault) : realVault.cfgGet;
|
|
622
|
-
if (!tracePracticesActive(cfgGet, cwd))
|
|
1685
|
+
if (size > STANDARDS_PLAN_READ_BYTES) {
|
|
1686
|
+
error(`The plan is ${size} bytes, above the ${STANDARDS_PLAN_READ_BYTES}-byte read cap the trace gate also applies, ` +
|
|
1687
|
+
`and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
623
1688
|
return;
|
|
624
|
-
|
|
625
|
-
const
|
|
626
|
-
const
|
|
627
|
-
if (
|
|
628
|
-
|
|
1689
|
+
}
|
|
1690
|
+
const text = vault.readHeadBounded(artifactAbs, STANDARDS_PLAN_READ_BYTES);
|
|
1691
|
+
const parsed = vaultStandardsLeaf.parseStandardsBlock(text, { artifact: shown });
|
|
1692
|
+
if (parsed.record === null || parsed.problems.length > 0) {
|
|
1693
|
+
const first = parsed.problems[0];
|
|
1694
|
+
error(first
|
|
1695
|
+
? `Vault standards consulted: ${first.kind}: ${first.detail}`
|
|
1696
|
+
: `Vault standards consulted: ${shown} carries no readable ${vaultStandardsLeaf.VAULT_STANDARDS_TAG_OPEN} record. ` +
|
|
1697
|
+
'Re-run `pp-tools vault standards block --plan <plan-id> <index-path...>` and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
|
|
629
1698
|
return;
|
|
630
1699
|
}
|
|
631
|
-
|
|
632
|
-
|
|
1700
|
+
const record = parsed.record;
|
|
1701
|
+
// ── absolute cache paths (D-30, C-9) ──
|
|
1702
|
+
// Resolved through the one cache resolver, so an executor in a linked
|
|
1703
|
+
// worktree (which has no cache of its own) is pointed at the main checkout's.
|
|
1704
|
+
// Every path is contained in the cache directory: a block edited by hand
|
|
1705
|
+
// could name `../x.md`, and that must be refused before anyone reads it.
|
|
633
1706
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
634
|
-
const
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1707
|
+
const indexLeaf = require('./vault-index.cjs');
|
|
1708
|
+
const cache = owner.resolveVaultCacheDir(cwd);
|
|
1709
|
+
// And every path is a RESOLVED INDEX entry of that cache (D-19, 15-REVIEW
|
|
1710
|
+
// WR-04): the executor is told to read each listed document in full, and a
|
|
1711
|
+
// withheld or credential file is in the cache too. The same reads and the
|
|
1712
|
+
// same set the block verb selects from, so a PLAN that never passed
|
|
1713
|
+
// plan:post, or was edited after it, cannot hand such a path to an executor.
|
|
1714
|
+
// One reason-free wording, as the block verb's: the refusal never says why.
|
|
1715
|
+
const loadedPin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cache.dir));
|
|
1716
|
+
const indexState = loadedPin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cache.dir });
|
|
1717
|
+
const selectable = vaultStandardsLeaf.standardsResolvedPaths(indexState);
|
|
1718
|
+
// 15-REVIEW iter-2 WR-05: with no vault loaded, or a loaded vault whose INDEX
|
|
1719
|
+
// is degraded, NO document can be listed, and the per-path wording below
|
|
1720
|
+
// would name a loaded vault that is not there and send the reader back to
|
|
1721
|
+
// `vault standards block`, where the only record left to write is a degraded
|
|
1722
|
+
// one: a governed plan would lose its document record. So this case is
|
|
1723
|
+
// refused first, naming the measured cause and the repair (`vault fetch`),
|
|
1724
|
+
// from the one owner the block verb and the trace gate also read (iter-3
|
|
1725
|
+
// WR-01), so the gate cannot send the same record somewhere else.
|
|
1726
|
+
const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(loadedPin, indexState);
|
|
1727
|
+
const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
|
|
1728
|
+
if (record.documents.length > 0 && indexFix !== null) {
|
|
1729
|
+
error(`Vault standards consulted: ${indexFix.cause}, so no document of ${shown} can be listed. ` +
|
|
1730
|
+
`${indexFix.repair}, then re-run this command.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
const documents = [];
|
|
1734
|
+
for (const d of record.documents) {
|
|
1735
|
+
const cachePath = node_path_1.default.join(cache.dir, d.path);
|
|
1736
|
+
if (node_path_1.default.isAbsolute(d.path) || !owner.isInsideDir(cache.dir, cachePath) || cachePath === cache.dir) {
|
|
1737
|
+
error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} resolves outside the vault cache ` +
|
|
1738
|
+
'and was not listed. Re-run `pp-tools vault standards block` for this plan and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
|
|
643
1739
|
return;
|
|
644
1740
|
}
|
|
1741
|
+
if (!selectable.has(d.path)) {
|
|
1742
|
+
error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} is not a selectable INDEX entry ` +
|
|
1743
|
+
'of the loaded vault and was not listed. Re-run `pp-tools vault standards block` for this plan, choosing from ' +
|
|
1744
|
+
'the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
documents.push({ path: d.path, ref: d.ref, sha: d.sha, cachePath });
|
|
645
1748
|
}
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
1749
|
+
// 16-06 (D-14/D-31/D-35): the anchor line, the section's last line. END is
|
|
1750
|
+
// HEAD now (the executor runs this after the last task commit); START is the
|
|
1751
|
+
// anchor `vault anchor start` recorded in the plan's own directory, when one
|
|
1752
|
+
// is there. Both come from git and the anchors file, never from the agent.
|
|
1753
|
+
// HEAD unreadable (no commit, git failing) writes no line at all, and the
|
|
1754
|
+
// strict points then type the missing anchor (16-07); a write point is
|
|
1755
|
+
// silent on it (D-16).
|
|
1756
|
+
const headRun = exec(['rev-parse', 'HEAD'], { cwd });
|
|
1757
|
+
const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
|
|
1758
|
+
const start = vaultSeal.readDirAnchors(node_path_1.default.dirname(artifactAbs))?.start ?? null;
|
|
1759
|
+
const anchors = vaultSeal.ANCHOR_SHA_RE.test(end) ? { start, end } : null;
|
|
1760
|
+
const section = vaultStandardsLeaf.serializeConsultedSection(record, { anchors });
|
|
1761
|
+
const envelope = owner.withVaultMode({
|
|
1762
|
+
status: 'ok',
|
|
1763
|
+
planId: record.planId,
|
|
1764
|
+
stamp: { repo: record.repo, ref: record.ref, sha: record.sha },
|
|
1765
|
+
documents,
|
|
1766
|
+
// The section and this field echo one sanitised line (15-REVIEW WR-03).
|
|
1767
|
+
degraded: record.degraded === null ? null : vaultStandardsLeaf.standardsEchoDegraded(record.degraded),
|
|
1768
|
+
cacheSource: cache.source,
|
|
1769
|
+
section,
|
|
1770
|
+
}, cwd);
|
|
1771
|
+
c.output(envelope, true, raw ? JSON.stringify(envelope) : section);
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* The real vault module, for the governance reads of the 16-06 verbs: the
|
|
1775
|
+
* admission verdict, containment and phase resolution must not be switchable
|
|
1776
|
+
* by a test mock (the mode-guard precedent in `routeVaultCommand`).
|
|
1777
|
+
*/
|
|
1778
|
+
function anchorOwner() {
|
|
1779
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1780
|
+
return require('./vault.cjs');
|
|
1781
|
+
}
|
|
1782
|
+
/** The project root the router's other containment checks use: `findProjectRoot(cwd) || cwd`. */
|
|
1783
|
+
function projectRootOf(cwd) {
|
|
652
1784
|
try {
|
|
653
|
-
|
|
1785
|
+
return findProjectRoot(cwd) || cwd;
|
|
654
1786
|
}
|
|
655
1787
|
catch {
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
const phase = checkRouter.resolveDecisionPhaseDir(root, cwd, phaseArg);
|
|
659
|
-
if (!phase) {
|
|
660
|
-
const unresolved = {
|
|
661
|
-
status: 'unresolved',
|
|
662
|
-
block: true,
|
|
663
|
-
message: `Vault audit: "${boundedPath(phaseArg)}" is neither an existing directory inside the project nor a `
|
|
664
|
-
+ 'phase the locator resolves, so no practice was compared against anything. Repair: re-run this '
|
|
665
|
-
+ 'command with the phase directory or number.',
|
|
666
|
-
};
|
|
667
|
-
c.output(unresolved, true, raw ? JSON.stringify(unresolved) : unresolved.message);
|
|
668
|
-
return;
|
|
1788
|
+
return cwd;
|
|
669
1789
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1790
|
+
}
|
|
1791
|
+
/**
|
|
1792
|
+
* THE CONTAINMENT LADDER for a path a 16-06 verb is handed (the
|
|
1793
|
+
* `standardsConsultedVerb` order, T-03-50): a NUL byte is refused, the project
|
|
1794
|
+
* root is resolved, the path must resolve inside the root AND inside its
|
|
1795
|
+
* `.planning/` tree (never the tree itself), and only then is it stat'ed.
|
|
1796
|
+
*
|
|
1797
|
+
* - `dir`: an existing directory;
|
|
1798
|
+
* - `file`: an existing regular file (not a symlink);
|
|
1799
|
+
* - `artifact`: a file that may not exist yet, whose directory exists inside
|
|
1800
|
+
* `.planning/`; when it does exist it must be a regular file (not a
|
|
1801
|
+
* symlink), so a write can never be redirected out of the tree.
|
|
1802
|
+
*
|
|
1803
|
+
* `.planning/` is required, not only the root, because every path these verbs
|
|
1804
|
+
* write or read is a planning record; an anchors file or a door record in the
|
|
1805
|
+
* project's source tree would be committed as source (D-55). The refusal names
|
|
1806
|
+
* the bounded argv value, never file content.
|
|
1807
|
+
*/
|
|
1808
|
+
function resolvePlanningPath(cwd, raw, want) {
|
|
1809
|
+
const shown = boundedPath(raw);
|
|
1810
|
+
if (raw.indexOf('\u0000') !== -1)
|
|
1811
|
+
return { ok: false, reason: 'path-unusable', message: `"${shown}" is not a usable path` };
|
|
1812
|
+
const owner = anchorOwner();
|
|
1813
|
+
const root = projectRootOf(cwd);
|
|
1814
|
+
const abs = node_path_1.default.resolve(cwd, raw);
|
|
1815
|
+
if (!owner.isInsideDir(root, abs)) {
|
|
1816
|
+
return { ok: false, reason: 'outside-project', message: `"${shown}" resolves outside the project and was not used` };
|
|
1817
|
+
}
|
|
1818
|
+
const planning = node_path_1.default.join(root, '.planning');
|
|
1819
|
+
const under = want === 'artifact' ? node_path_1.default.dirname(abs) : abs;
|
|
1820
|
+
if (!owner.isInsideDir(planning, under) || (want !== 'artifact' && owner.isInsideDir(abs, planning))) {
|
|
1821
|
+
return { ok: false, reason: 'outside-planning', message: `"${shown}" is not inside the project's .planning/ tree` };
|
|
1822
|
+
}
|
|
1823
|
+
const rel = node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/');
|
|
1824
|
+
try {
|
|
1825
|
+
if (want === 'dir') {
|
|
1826
|
+
if (!node_fs_1.default.statSync(abs).isDirectory())
|
|
1827
|
+
return { ok: false, reason: 'not-a-directory', message: `"${shown}" is not a directory` };
|
|
1828
|
+
}
|
|
1829
|
+
else if (want === 'file') {
|
|
1830
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
1831
|
+
if (!st.isFile())
|
|
1832
|
+
return { ok: false, reason: 'not-a-file', message: `"${shown}" is not a regular file` };
|
|
1833
|
+
}
|
|
1834
|
+
else {
|
|
1835
|
+
if (!node_fs_1.default.statSync(node_path_1.default.dirname(abs)).isDirectory()) {
|
|
1836
|
+
return { ok: false, reason: 'not-a-directory', message: `the directory of "${shown}" does not exist` };
|
|
1837
|
+
}
|
|
1838
|
+
if (node_fs_1.default.existsSync(abs) || isDanglingLink(abs)) {
|
|
1839
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
1840
|
+
if (!st.isFile())
|
|
1841
|
+
return { ok: false, reason: 'not-a-file', message: `"${shown}" exists and is not a regular file` };
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
catch {
|
|
1846
|
+
return { ok: false, reason: 'not-found', message: `"${shown}" does not exist` };
|
|
1847
|
+
}
|
|
1848
|
+
return { ok: true, root, abs, rel };
|
|
1849
|
+
}
|
|
1850
|
+
/** A symlink whose target is missing: `existsSync` says false, and a write would follow it. */
|
|
1851
|
+
function isDanglingLink(abs) {
|
|
1852
|
+
try {
|
|
1853
|
+
return node_fs_1.default.lstatSync(abs).isSymbolicLink();
|
|
1854
|
+
}
|
|
1855
|
+
catch {
|
|
1856
|
+
return false;
|
|
1857
|
+
}
|
|
1858
|
+
}
|
|
1859
|
+
/**
|
|
1860
|
+
* Where the execute-stage scaffold reads a plan's START anchor from, derived
|
|
1861
|
+
* from `--plan` (16-06, D-31). Measured order:
|
|
1862
|
+
*
|
|
1863
|
+
* 1. the QUICK form first: the one directory `<root>/.planning/quick/<id>-*`
|
|
1864
|
+
* (quick task directories always live under the ROOT planning tree,
|
|
1865
|
+
* `src/init.cts`). Exactly one match resolves; two or more is ambiguous
|
|
1866
|
+
* and resolves nothing. Tried for every id, so an all-digit quick id
|
|
1867
|
+
* (`261011-123`, which the phase alternative of `SCAFFOLD_PLAN_ID_RE`
|
|
1868
|
+
* also accepts) still finds its quick directory, and no second copy of
|
|
1869
|
+
* the quick-id form is needed here;
|
|
1870
|
+
* 2. otherwise the PHASE form: the segment before the first `-` (`02.1` of
|
|
1871
|
+
* `02.1-03`) resolved by `resolveTracePhaseDir`, the resolver the trace
|
|
1872
|
+
* gate uses for a phase-number positional, which honours the active
|
|
1873
|
+
* workstream (`--ws`, `GSD_WORKSTREAM`, the session pointer) through the
|
|
1874
|
+
* phase locator.
|
|
1875
|
+
*
|
|
1876
|
+
* Unresolvable → `ok: false`, and the scaffold writes `endCommit` only.
|
|
1877
|
+
*/
|
|
1878
|
+
function resolveScaffoldAnchorsDir(cwd, planId) {
|
|
1879
|
+
const root = projectRootOf(cwd);
|
|
1880
|
+
const quickRoot = node_path_1.default.join(root, '.planning', 'quick');
|
|
1881
|
+
let quick = [];
|
|
1882
|
+
try {
|
|
1883
|
+
quick = node_fs_1.default.readdirSync(quickRoot, { withFileTypes: true })
|
|
1884
|
+
.filter((d) => d.isDirectory() && d.name.startsWith(`${planId}-`))
|
|
1885
|
+
.map((d) => d.name);
|
|
1886
|
+
}
|
|
1887
|
+
catch {
|
|
1888
|
+
quick = [];
|
|
1889
|
+
}
|
|
1890
|
+
if (quick.length === 1) {
|
|
1891
|
+
const abs = node_path_1.default.join(quickRoot, quick[0]);
|
|
1892
|
+
return { ok: true, root, abs, rel: node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/') };
|
|
1893
|
+
}
|
|
1894
|
+
if (quick.length > 1) {
|
|
1895
|
+
return { ok: false, reason: 'anchors-dir-ambiguous', message: `${quick.length} quick directories match .planning/quick/${boundedPath(planId)}-*` };
|
|
1896
|
+
}
|
|
1897
|
+
const dash = planId.indexOf('-');
|
|
1898
|
+
if (dash > 0) {
|
|
1899
|
+
const resolved = anchorOwner().resolveTracePhaseDir(root, cwd, planId.slice(0, dash));
|
|
1900
|
+
if (resolved && resolved.onlyFile === undefined)
|
|
1901
|
+
return { ok: true, root, abs: resolved.abs, rel: resolved.rel };
|
|
1902
|
+
}
|
|
1903
|
+
return { ok: false, reason: 'anchors-dir-unresolved', message: `no phase or quick directory resolves for plan ${boundedPath(planId)}` };
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* `vault anchor <verb>` (16-06). INERT FIRST (D-13): unless the producer
|
|
1907
|
+
* admission verdict is `on` (bindings present, every config readable, the
|
|
1908
|
+
* vault gate active) the verb prints nothing and writes nothing, before any
|
|
1909
|
+
* argument is read, so a project without the vault is byte-identical on every
|
|
1910
|
+
* path, including a mistyped one. A `config-unreadable` project is halted by
|
|
1911
|
+
* the admission fence that precedes every call site, never here.
|
|
1912
|
+
*
|
|
1913
|
+
* The verdict is the PRODUCER's (quick 261015-ect, QD-10), the one the other
|
|
1914
|
+
* six record producers ask: equal to the cwd's own except in a linked worktree
|
|
1915
|
+
* with no bindings of its own, where it is the owning checkout's. So all seven
|
|
1916
|
+
* answer alike in every shape; the door fences, which probe the cwd's bindings
|
|
1917
|
+
* first, never reach this verb in such a worktree, so only by-hand calls move.
|
|
1918
|
+
*/
|
|
1919
|
+
function routeVaultAnchor({ cwd, args, raw, error, c, exec }) {
|
|
1920
|
+
if (anchorOwner().vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
1921
|
+
return;
|
|
1922
|
+
const verb = args[2];
|
|
1923
|
+
const rest = args.slice(3);
|
|
1924
|
+
if (verb === 'start') {
|
|
1925
|
+
anchorStartVerb({ cwd, rest, raw, error, c, exec });
|
|
1926
|
+
}
|
|
1927
|
+
else if (verb === 'backfill') {
|
|
1928
|
+
anchorBackfillVerb({ cwd, rest, raw, error, c, exec });
|
|
1929
|
+
}
|
|
1930
|
+
else if (verb === 'certify') {
|
|
1931
|
+
anchorCertifyVerb({ cwd, rest, raw, error, c, exec });
|
|
680
1932
|
}
|
|
681
1933
|
else {
|
|
682
|
-
|
|
1934
|
+
error(`Unknown vault anchor verb. Available: ${ANCHOR_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
683
1935
|
}
|
|
684
1936
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
1937
|
+
/**
|
|
1938
|
+
* `vault anchor start <dir>` (16-06, D-31): record HEAD as the START anchor of
|
|
1939
|
+
* a phase or quick directory, in `<dir>/.vault-anchors.json`
|
|
1940
|
+
* (`{ start, recordedAt }`, written by the seal leaf's `writeDirStartAnchor`).
|
|
1941
|
+
* Mode-neutral: it is the start the practices-only `vault manifest start`
|
|
1942
|
+
* cannot give a standards-mode phase.
|
|
1943
|
+
*
|
|
1944
|
+
* Re-running at the SAME HEAD keeps the recorded start (`kept: true`, nothing
|
|
1945
|
+
* written). At any other HEAD it records HEAD as the new start and says which
|
|
1946
|
+
* start it superseded (`replaced`) — 16 review iteration 2 CR-02, D-98, which
|
|
1947
|
+
* reverses D-93's "keep the first start". Keeping it let every record written
|
|
1948
|
+
* after a resume carry the first start, so its range covered a commit made
|
|
1949
|
+
* between the two runs: a claim of governance nothing granted (rule 3). The
|
|
1950
|
+
* superseded start is kept in the directory's history, newest first, and an
|
|
1951
|
+
* END-only record (a linked worktree's, D-59) takes the latest start that
|
|
1952
|
+
* precedes its own end, so a record written before the resume still covers
|
|
1953
|
+
* its own work. Commits made between the last record and the resume are
|
|
1954
|
+
* covered by nothing and the strict gate names them; the phase's own work
|
|
1955
|
+
* among them is repaired with the attributed start back-fill
|
|
1956
|
+
* (`vault anchor backfill <summary> --from <commit>`), never by a silent
|
|
1957
|
+
* widening.
|
|
1958
|
+
*
|
|
1959
|
+
* Exit 0 on every refusal, which travels as `{ ok: false, reason, message }`
|
|
1960
|
+
* (the `vault manifest` posture: the step prints it and continues; the strict
|
|
1961
|
+
* points type a missing start). An argument error is the only exit 1.
|
|
1962
|
+
*/
|
|
1963
|
+
function anchorStartVerb({ cwd, rest, raw, error, c, exec }) {
|
|
1964
|
+
const positionals = [];
|
|
1965
|
+
for (const a of rest) {
|
|
1966
|
+
if (a === '--raw')
|
|
1967
|
+
continue;
|
|
1968
|
+
if (a.startsWith('--')) {
|
|
1969
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault anchor start. Supported: a positional directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
positionals.push(a);
|
|
1973
|
+
}
|
|
1974
|
+
if (positionals.length !== 1) {
|
|
1975
|
+
error('vault anchor start takes exactly one directory. Usage: vault anchor start <phase-or-quick-dir> [--raw].', positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
const emit = (result, text) => {
|
|
1979
|
+
c.output(result, true, raw ? JSON.stringify(result) : text);
|
|
1980
|
+
};
|
|
1981
|
+
const located = resolvePlanningPath(cwd, positionals[0], 'dir');
|
|
1982
|
+
if (!located.ok) {
|
|
1983
|
+
emit({ ok: false, action: 'start', reason: located.reason, message: located.message }, `No start anchor was recorded: ${located.message}.`);
|
|
1984
|
+
return;
|
|
1985
|
+
}
|
|
1986
|
+
const head = exec(['rev-parse', 'HEAD'], { cwd });
|
|
1987
|
+
const sha = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
1988
|
+
if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
|
|
1989
|
+
const message = 'HEAD could not be read as a full commit id (no commit yet, or git failed)';
|
|
1990
|
+
emit({ ok: false, action: 'start', reason: 'head-unreadable', message }, `No start anchor was recorded: ${message}.`);
|
|
1991
|
+
return;
|
|
1992
|
+
}
|
|
1993
|
+
const file = `${located.rel}/${vaultSeal.VAULT_ANCHORS_FILE}`;
|
|
1994
|
+
const existing = vaultSeal.readDirAnchors(located.abs);
|
|
1995
|
+
if (existing !== null && existing.start === sha) {
|
|
1996
|
+
emit({ ok: true, action: 'start', start: existing.start, recordedAt: existing.recordedAt, file, kept: true }, `Kept the start anchor ${existing.start} already recorded in ${file} (HEAD has not moved since it was recorded).`);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
let recorded;
|
|
2000
|
+
try {
|
|
2001
|
+
recorded = vaultSeal.writeDirStartAnchor(located.abs, sha);
|
|
2002
|
+
}
|
|
2003
|
+
catch {
|
|
2004
|
+
const message = `${file} could not be written`;
|
|
2005
|
+
emit({ ok: false, action: 'start', reason: 'write-failed', message }, `No start anchor was recorded: ${message}.`);
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
const replaced = existing !== null ? { replaced: existing.start } : {};
|
|
2009
|
+
emit({ ok: true, action: 'start', start: recorded.start, recordedAt: recorded.recordedAt, file, ...replaced }, existing !== null
|
|
2010
|
+
? `Recorded start anchor ${recorded.start} in ${file}, replacing ${existing.start} as the start of every record written from now on; `
|
|
2011
|
+
+ 'the earlier start stays in the directory\'s history for a record that ended before this one.'
|
|
2012
|
+
: `Recorded start anchor ${recorded.start} in ${file}.`);
|
|
690
2013
|
}
|
|
2014
|
+
// ─── 16-19: the certificate writer (D-107, D-108) ────────────────────────────
|
|
2015
|
+
/** How long the strict check `vault anchor certify` runs may take before it is a `check-failed` refusal. */
|
|
2016
|
+
const CERTIFY_CHECK_TIMEOUT_MS = 120_000;
|
|
2017
|
+
/** The most stdout the certify verb reads from its child check (a larger envelope is `check-failed`). */
|
|
2018
|
+
const CERTIFY_CHECK_MAX_BUFFER = 8 * 1024 * 1024;
|
|
2019
|
+
/** At most this many problem kinds, added ends or removed ends travel in one certify refusal. */
|
|
2020
|
+
const CERTIFY_LIST_CAP = 20;
|
|
2021
|
+
/** How much of the child check's stderr a `check-failed` refusal quotes. */
|
|
2022
|
+
const CERTIFY_STDERR_HEAD = 120;
|
|
691
2023
|
/**
|
|
692
|
-
* `vault
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
2024
|
+
* `vault anchor certify <phase-dir>` (16 gap closure 16-19, D-107, D-108): the
|
|
2025
|
+
* ONE writer of a phase directory's `.vault-certificates.json`. A certificate
|
|
2026
|
+
* says "a clean strict check of this phase judged this SUMMARY's END, at this
|
|
2027
|
+
* HEAD, at this time"; only a certified phase record bounds a later phase's
|
|
2028
|
+
* SEAL-01 reach (16-18), so the certificate is the recorded fact D-101 said the
|
|
2029
|
+
* gate did not have.
|
|
2030
|
+
*
|
|
2031
|
+
* WHY A CHILD PROCESS. `check vault.trace` is read-only by contract (TRACE-04)
|
|
2032
|
+
* and its handler emits to stdout. Running it as the enforcer does, as
|
|
2033
|
+
* `pp-tools check vault.trace <dir> --unpaired strict --raw`, keeps its exit-0
|
|
2034
|
+
* posture and its envelope exactly what every gate reads, and this verb only
|
|
2035
|
+
* reads that envelope. The engine is the one running (`pp-tools.cjs` beside
|
|
2036
|
+
* this module's directory); the positional is the contained rel, after the
|
|
2037
|
+
* subcommand, never in an option position.
|
|
2038
|
+
*
|
|
2039
|
+
* WHY THE ENDS AND HEAD ARE COLLECTED BEFORE THE SPAWN, AND RE-CHECKED AFTER.
|
|
2040
|
+
* A certificate for an end the check never judged, or stamped with a HEAD the
|
|
2041
|
+
* check did not judge at, is a certificate for a check that never passed: the
|
|
2042
|
+
* shape D-106 closed at the leaf, reopened by its writer. So HEAD is read and
|
|
2043
|
+
* the phase's ended SUMMARY records are collected first, only those ends are
|
|
2044
|
+
* certified, and after the child returns a different HEAD is `head-moved` and a
|
|
2045
|
+
* different set of ends is `records-changed`, each writing nothing. The verb
|
|
2046
|
+
* refuses rather than certifying the intersection, so a concurrent writer is
|
|
2047
|
+
* visible and never silently narrowed; a SUMMARY added while the check ran is
|
|
2048
|
+
* certified by the next clean run.
|
|
2049
|
+
*
|
|
2050
|
+
* WHY THE GIT ENVIRONMENT IS SCRUBBED, FOR THE VERB'S READS AND THE CHILD
|
|
2051
|
+
* ALIKE. A git hook or an agent shell exports the repository pointer
|
|
2052
|
+
* (`GIT_DIR`, `GIT_WORK_TREE`, …), so an inherited value is ordinary, and a
|
|
2053
|
+
* lasting certificate must never describe another repository's HEAD or
|
|
2054
|
+
* verdict. The overlay comes from its one owner, `scrubbedGitEnv` in
|
|
2055
|
+
* `src/vault.cts` (the cache resolver's precedent, 260915-mzx R2): every
|
|
2056
|
+
* inherited `GIT_*` key is `undefined`, which unsets it, because neither
|
|
2057
|
+
* `execGit` nor `spawnSync` passes an undefined value on.
|
|
2058
|
+
*
|
|
2059
|
+
* WHY THE OVERRIDE VARIABLE IS EMPTIED (D-108). An override never certifies:
|
|
2060
|
+
* `checked_with_override`, an envelope carrying `override` and one carrying
|
|
2061
|
+
* `overrideRejected` are each refused, and the child's `VAULT_OVERRIDE_ENV` is
|
|
2062
|
+
* the empty string (which the trace leaf reads as absent), so an operator's
|
|
2063
|
+
* per-run override in the calling shell can never turn a blocking check into a
|
|
2064
|
+
* certificate, whatever a later engine's check does with that variable.
|
|
2065
|
+
*
|
|
2066
|
+
* WHY A QUICK DIRECTORY IS REFUSED (D-104). A quick task's record never bounds
|
|
2067
|
+
* a later phase, so it is never certified (D-113 keeps the leaf from reading
|
|
2068
|
+
* one as certified even if a file named its end).
|
|
2069
|
+
*
|
|
2070
|
+
* A skipped call certifies nothing, which only widens the next phase's reach
|
|
2071
|
+
* and never hides a commit (D-110). Exit 0 on every refusal, which travels as
|
|
2072
|
+
* `{ ok: false, action: 'certify', reason, … }`; an argument error is the only
|
|
2073
|
+
* exit 1. Inert before any argument is read (`routeVaultAnchor`, D-13).
|
|
2074
|
+
*/
|
|
2075
|
+
function anchorCertifyVerb({ cwd, rest, raw, error, c, exec }) {
|
|
2076
|
+
const positionals = [];
|
|
2077
|
+
for (const a of rest) {
|
|
2078
|
+
if (a === '--raw')
|
|
2079
|
+
continue;
|
|
2080
|
+
if (a.startsWith('--')) {
|
|
2081
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault anchor certify. Supported: a positional phase directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2082
|
+
return;
|
|
2083
|
+
}
|
|
2084
|
+
positionals.push(a);
|
|
2085
|
+
}
|
|
2086
|
+
if (positionals.length !== 1) {
|
|
2087
|
+
error('vault anchor certify takes exactly one phase directory. Usage: vault anchor certify <phase-dir> [--raw].', positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
const emit = (result, text) => {
|
|
2091
|
+
c.output(result, true, raw ? JSON.stringify(result) : text);
|
|
713
2092
|
};
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
2093
|
+
const refuse = (reason, message, extra = {}) => {
|
|
2094
|
+
emit({ ok: false, action: 'certify', reason, ...extra, message }, `Nothing was certified: ${message}.`);
|
|
2095
|
+
};
|
|
2096
|
+
const located = resolvePlanningPath(cwd, positionals[0], 'dir');
|
|
2097
|
+
if (!located.ok) {
|
|
2098
|
+
refuse(located.reason, located.message);
|
|
2099
|
+
return;
|
|
2100
|
+
}
|
|
2101
|
+
if (!vaultSeal.isPhaseRecordRel(`${located.rel}/x`)) {
|
|
2102
|
+
refuse('not-a-phase-dir', `${located.rel} is not a phase directory; a quick task's or a door's record never bounds a later phase (D-104), so it is never certified`);
|
|
2103
|
+
return;
|
|
2104
|
+
}
|
|
2105
|
+
const gitEnv = anchorOwner().scrubbedGitEnv(process.env);
|
|
2106
|
+
const readHead = () => {
|
|
2107
|
+
const r = exec(['rev-parse', 'HEAD'], { cwd, env: gitEnv });
|
|
2108
|
+
const sha = r.exitCode === 0 ? r.stdout.trim() : '';
|
|
2109
|
+
return vaultSeal.ANCHOR_SHA_RE.test(sha) ? sha : '';
|
|
2110
|
+
};
|
|
2111
|
+
const head = readHead();
|
|
2112
|
+
if (head === '') {
|
|
2113
|
+
refuse('head-unreadable', 'HEAD could not be read as a full commit id (no commit yet, or git failed)');
|
|
2114
|
+
return;
|
|
2115
|
+
}
|
|
2116
|
+
// A filesystem walk, no git: the ends of the SUMMARY records directly in this
|
|
2117
|
+
// directory, sorted and unique.
|
|
2118
|
+
const collectEnds = () => {
|
|
2119
|
+
const scan = vaultSeal.collectCoveringRecords(located.root);
|
|
2120
|
+
const prefix = `${located.rel}/`;
|
|
2121
|
+
const ends = scan.records
|
|
2122
|
+
.filter((r) => vaultSeal.isSummaryRecordRel(r.rel) && r.rel.startsWith(prefix) && r.rel.indexOf('/', prefix.length) === -1)
|
|
2123
|
+
.map((r) => r.end);
|
|
2124
|
+
return [...new Set(ends)].sort();
|
|
2125
|
+
};
|
|
2126
|
+
const endsBefore = collectEnds();
|
|
2127
|
+
if (endsBefore.length === 0) {
|
|
2128
|
+
const message = `${located.rel} has no SUMMARY with an end anchor, so there is nothing to certify; the strict check was not run`;
|
|
2129
|
+
emit({ ok: false, action: 'certify', reason: 'no-ended-record', message }, `Nothing was certified: ${message}.`);
|
|
719
2130
|
return;
|
|
720
2131
|
}
|
|
721
|
-
const force = verb === 'scaffold' && args.includes('--force');
|
|
722
|
-
const tokens = args.slice(3).filter((a) => a !== '--raw' && !(force && a === '--force'));
|
|
723
2132
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
724
|
-
const
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
2133
|
+
const { VAULT_OVERRIDE_ENV } = require('./vault-trace.cjs');
|
|
2134
|
+
const child = childProcess.spawnSync(process.execPath, [node_path_1.default.join(__dirname, '..', 'pp-tools.cjs'), 'check', 'vault.trace', located.rel, '--unpaired', 'strict', '--raw'], {
|
|
2135
|
+
cwd: located.root,
|
|
2136
|
+
encoding: 'utf8',
|
|
2137
|
+
timeout: CERTIFY_CHECK_TIMEOUT_MS,
|
|
2138
|
+
maxBuffer: CERTIFY_CHECK_MAX_BUFFER,
|
|
2139
|
+
env: { ...process.env, ...gitEnv, [VAULT_OVERRIDE_ENV]: '' },
|
|
2140
|
+
windowsHide: true,
|
|
2141
|
+
});
|
|
2142
|
+
const stderrHead = boundedPath(String(child.stderr ?? '').slice(0, CERTIFY_STDERR_HEAD));
|
|
2143
|
+
const failed = (why) => {
|
|
2144
|
+
refuse('check-failed', `the strict check of ${located.rel} could not be read (${why})`, { detail: `${why}; stderr: ${stderrHead}` });
|
|
2145
|
+
};
|
|
2146
|
+
if (child.error) {
|
|
2147
|
+
failed(`the check did not run: ${boundedPath(child.error.message).slice(0, CERTIFY_STDERR_HEAD)}`);
|
|
2148
|
+
return;
|
|
2149
|
+
}
|
|
2150
|
+
if (child.signal) {
|
|
2151
|
+
failed(`the check was ended by ${child.signal}`);
|
|
2152
|
+
return;
|
|
2153
|
+
}
|
|
2154
|
+
if (child.status !== 0) {
|
|
2155
|
+
failed(`the check exited ${child.status}`);
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
const stdout = String(child.stdout ?? '');
|
|
2159
|
+
if (stdout.trim() === '') {
|
|
2160
|
+
failed('the check printed nothing');
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
let envelope;
|
|
2164
|
+
try {
|
|
2165
|
+
const parsed = JSON.parse(stdout);
|
|
2166
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
2167
|
+
failed('the check printed JSON that is not an object');
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
envelope = parsed;
|
|
2171
|
+
}
|
|
2172
|
+
catch {
|
|
2173
|
+
failed('the check printed something that is not JSON');
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
// The verdict gate (D-107, D-108): a clean strict verdict, and nothing else.
|
|
2177
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(envelope, k);
|
|
2178
|
+
const status = typeof envelope.status === 'string' ? boundedPath(envelope.status) : null;
|
|
2179
|
+
const block = typeof envelope.block === 'boolean' ? envelope.block : null;
|
|
2180
|
+
const clean = envelope.status === 'checked' && envelope.block === false && !has('override') && !has('overrideRejected');
|
|
2181
|
+
if (!clean) {
|
|
2182
|
+
const problems = Array.isArray(envelope.problems) ? envelope.problems : [];
|
|
2183
|
+
const kinds = [...new Set(problems
|
|
2184
|
+
.map((p) => (p && typeof p === 'object' ? p.kind : undefined))
|
|
2185
|
+
.filter((k) => typeof k === 'string')
|
|
2186
|
+
.map((k) => boundedPath(k)))].sort().slice(0, CERTIFY_LIST_CAP);
|
|
2187
|
+
const overridden = envelope.status === 'checked_with_override' || has('override');
|
|
2188
|
+
refuse(overridden ? 'overridden' : 'not-clean', `the strict check of ${located.rel} answered ${status ?? 'no status'}, block ${String(block)}`
|
|
2189
|
+
+ `${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}`
|
|
2190
|
+
+ `${overridden ? '; an override never certifies (D-108)' : ''}`, { status, block, kinds });
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
// After the child returned clean: the HEAD it judged at, then the ends it judged.
|
|
2194
|
+
const headAfter = readHead();
|
|
2195
|
+
if (headAfter !== head) {
|
|
2196
|
+
refuse('head-moved', `HEAD moved from ${head} to ${headAfter || 'an unreadable HEAD'} while the strict check of ${located.rel} ran; re-run certify`, { head, headAfter: headAfter || null });
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
const endsAfter = collectEnds();
|
|
2200
|
+
const beforeSet = new Set(endsBefore);
|
|
2201
|
+
const afterSet = new Set(endsAfter);
|
|
2202
|
+
const added = endsAfter.filter((e) => !beforeSet.has(e));
|
|
2203
|
+
const removed = endsBefore.filter((e) => !afterSet.has(e));
|
|
2204
|
+
if (added.length > 0 || removed.length > 0) {
|
|
2205
|
+
refuse('records-changed', `the ended records of ${located.rel} changed while its strict check ran (${added.length} added, ${removed.length} removed); re-run certify`, { added: added.slice(0, CERTIFY_LIST_CAP), removed: removed.slice(0, CERTIFY_LIST_CAP) });
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
// Only the ends collected BEFORE the spawn, stamped with the HEAD read before it.
|
|
2209
|
+
const file = `${located.rel}/${vaultSeal.VAULT_CERTIFICATES_FILE}`;
|
|
2210
|
+
const already = new Set(vaultSeal.readDirCertificates(located.abs).map((cert) => cert.end));
|
|
2211
|
+
const fresh = endsBefore.filter((e) => !already.has(e));
|
|
2212
|
+
const old = endsBefore.filter((e) => already.has(e));
|
|
2213
|
+
if (fresh.length > 0) {
|
|
2214
|
+
const at = new Date().toISOString();
|
|
2215
|
+
try {
|
|
2216
|
+
vaultSeal.appendDirCertificates(located.abs, fresh.map((end) => ({ end, head, at })));
|
|
2217
|
+
}
|
|
2218
|
+
catch (e) {
|
|
2219
|
+
refuse('write-failed', `${file} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
emit({ ok: true, action: 'certify', dir: located.rel, file, head, certified: fresh, alreadyCertified: old, ...(fresh.length === 0 ? { kept: true } : {}) }, fresh.length === 0
|
|
2224
|
+
? `Kept: every end anchor of ${located.rel} is already certified (${old.length}); nothing written.`
|
|
2225
|
+
: `Certified ${fresh.length} end anchor(s) of ${located.rel} at HEAD ${head} after its strict check passed clean (${old.length} already certified).`);
|
|
2226
|
+
}
|
|
2227
|
+
/**
|
|
2228
|
+
* The text's lines, split on the same boundaries the record parsers split on
|
|
2229
|
+
* (`\r\n`, `\r`, `\n`), with their offsets, so an insertion lands at a line the
|
|
2230
|
+
* parser counted and every other byte stays where it was.
|
|
2231
|
+
*/
|
|
2232
|
+
function lineSpans(text) {
|
|
2233
|
+
const spans = [];
|
|
2234
|
+
const re = /\r\n|\r|\n/g;
|
|
2235
|
+
let last = 0;
|
|
2236
|
+
let m;
|
|
2237
|
+
while ((m = re.exec(text)) !== null) {
|
|
2238
|
+
spans.push({ start: last, end: m.index, eol: m[0] });
|
|
2239
|
+
last = m.index + m[0].length;
|
|
2240
|
+
}
|
|
2241
|
+
spans.push({ start: last, end: text.length, eol: '' });
|
|
2242
|
+
return spans;
|
|
2243
|
+
}
|
|
2244
|
+
/** Insert `lines` as new lines directly after line `after`, in that line's own line ending. */
|
|
2245
|
+
function insertLinesAfter(text, spans, after, lines) {
|
|
2246
|
+
const eol = spans[after].eol || (spans.find((sp) => sp.eol !== '')?.eol ?? '\n');
|
|
2247
|
+
const at = spans[after].end;
|
|
2248
|
+
return `${text.slice(0, at)}${lines.map((l) => `${eol}${l}`).join('')}${text.slice(at)}`;
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* The index of the practices ledger's `generatedAt` line, the line the
|
|
2252
|
+
* serializer writes the anchors after, located the way the parser locates the
|
|
2253
|
+
* block: the frontmatter from line 0 to the first later line opening `---`, the
|
|
2254
|
+
* top-level `vault:` key, its `predicate:` child, and that child's own
|
|
2255
|
+
* `generatedAt:` key. Also returns the indent of the predicate's keys and one
|
|
2256
|
+
* indentation step, so the spliced lines sit at the ledger's own indentation.
|
|
2257
|
+
* `null` when any of these is missing.
|
|
2258
|
+
*/
|
|
2259
|
+
function ledgerGeneratedAtLine(text, spans) {
|
|
2260
|
+
const line = (i) => text.slice(spans[i].start, spans[i].end);
|
|
2261
|
+
const indentOf = (l) => l.length - l.trimStart().length;
|
|
2262
|
+
if (spans.length < 3 || line(0) !== '---')
|
|
2263
|
+
return null;
|
|
2264
|
+
let close = -1;
|
|
2265
|
+
for (let i = 1; i < spans.length; i += 1) {
|
|
2266
|
+
if (line(i).startsWith('---')) {
|
|
2267
|
+
close = i;
|
|
2268
|
+
break;
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
if (close === -1)
|
|
2272
|
+
return null;
|
|
2273
|
+
const vaultAt = Array.from({ length: close - 1 }, (_, k) => k + 1).find((i) => /^vault:\s*$/.test(line(i)));
|
|
2274
|
+
if (vaultAt === undefined)
|
|
2275
|
+
return null;
|
|
2276
|
+
let predicateAt = -1;
|
|
2277
|
+
let childIndent = -1;
|
|
2278
|
+
for (let i = vaultAt + 1; i < close; i += 1) {
|
|
2279
|
+
const l = line(i);
|
|
2280
|
+
if (l.trim() === '')
|
|
2281
|
+
continue;
|
|
2282
|
+
const ind = indentOf(l);
|
|
2283
|
+
if (ind === 0)
|
|
2284
|
+
break;
|
|
2285
|
+
if (childIndent === -1)
|
|
2286
|
+
childIndent = ind;
|
|
2287
|
+
if (ind === childIndent && l.trim() === 'predicate:') {
|
|
2288
|
+
predicateAt = i;
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
if (predicateAt === -1)
|
|
2293
|
+
return null;
|
|
2294
|
+
let keyIndent = -1;
|
|
2295
|
+
for (let i = predicateAt + 1; i < close; i += 1) {
|
|
2296
|
+
const l = line(i);
|
|
2297
|
+
if (l.trim() === '')
|
|
2298
|
+
continue;
|
|
2299
|
+
const ind = indentOf(l);
|
|
2300
|
+
if (ind <= childIndent)
|
|
2301
|
+
break;
|
|
2302
|
+
if (keyIndent === -1)
|
|
2303
|
+
keyIndent = ind;
|
|
2304
|
+
if (ind === keyIndent && l.trimStart().startsWith('generatedAt:')) {
|
|
2305
|
+
return { index: i, keyIndent, step: keyIndent - childIndent };
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
return null;
|
|
2309
|
+
}
|
|
2310
|
+
/** The parsed statement minus its anchor keys, as text, for the before/after equality the back-fill asserts. */
|
|
2311
|
+
function statementWithoutAnchors(statement) {
|
|
2312
|
+
const st = JSON.parse(JSON.stringify(statement ?? null));
|
|
2313
|
+
if (st && st.predicate) {
|
|
2314
|
+
for (const k of ['startCommit', 'endCommit', 'anchorBackfill', 'anchorsDropped'])
|
|
2315
|
+
delete st.predicate[k];
|
|
2316
|
+
}
|
|
2317
|
+
return JSON.stringify(st);
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* `vault anchor backfill <summary> --at <sha>` (16-06, D-32):
|
|
2321
|
+
* give a record written before anchors existed its anchors, ATTRIBUTED. There
|
|
2322
|
+
* is no silent restamp at HEAD: `--at` is required (the operator states which
|
|
2323
|
+
* commit the record's work ended at), it must be an ancestor of HEAD, and the
|
|
2324
|
+
* record gains, beside the anchor, who ran the back-fill (`git config
|
|
2325
|
+
* user.name`, bounded) and when. 16-07 reports a back-filled anchor as a note
|
|
2326
|
+
* on every later check. `--at HEAD` is allowed, and is still attributed.
|
|
2327
|
+
*
|
|
2328
|
+
* - practices (a SUMMARY's execute-stage `vault:` ledger, tried first):
|
|
2329
|
+
* `predicate.endCommit` = `--at` and `predicate.anchorBackfill: { by, at }`
|
|
2330
|
+
* (no start: the record takes its directory's, D-59), spliced after the
|
|
2331
|
+
* ledger's `generatedAt` line with the serializer's own lines
|
|
2332
|
+
* (`traceAnchorLines`), so a later `trace render` round trip agrees;
|
|
2333
|
+
* - standards (a `## Standards Consulted` section): the anchor line with its
|
|
2334
|
+
* `backfilled by <who> at <iso>` tail, inserted as the section's last line.
|
|
2335
|
+
*
|
|
2336
|
+
* Every other byte of the file is kept: the new text is the old one with lines
|
|
2337
|
+
* inserted, and it is parsed back before it is written — the anchors must read
|
|
2338
|
+
* back as written and the rest of the record must parse to exactly what it
|
|
2339
|
+
* parsed to before, or nothing is written. The write is a temporary sibling
|
|
2340
|
+
* renamed into place.
|
|
2341
|
+
*
|
|
2342
|
+
* Refused, writing nothing: a record that already carries an anchor (a valid,
|
|
2343
|
+
* a dropped or a malformed one: `already-anchored`), a file with no record of
|
|
2344
|
+
* either family (`no-record`), an `--at` that is not an ancestor of HEAD. A
|
|
2345
|
+
* path outside `.planning/`, a malformed sha and `--from`/`--attest` beside
|
|
2346
|
+
* `--at` are argument errors (exit 1), refused before any read or git call;
|
|
2347
|
+
* the state refusals are `ok: false` envelopes (exit 0).
|
|
2348
|
+
*
|
|
2349
|
+
* 16 review iteration 3 CR-01 (D-102): `--at --from <commit>` set a start of
|
|
2350
|
+
* the operator's choosing (the root commit, say), and D-98's `--from` alone
|
|
2351
|
+
* widened an anchored record's start the same way; either covered every commit
|
|
2352
|
+
* in between with one attribution and named none. Both are gone. The one repair
|
|
2353
|
+
* left for a commit a record's range misses is `--from <recorded start>
|
|
2354
|
+
* --attest <commit>` (`attestVerb`), which covers that commit alone.
|
|
2355
|
+
*/
|
|
2356
|
+
function anchorBackfillVerb({ cwd, rest, raw, error, c, exec }) {
|
|
2357
|
+
const usage = 'Usage: vault anchor backfill <summary-path> --at <commit> [--raw], or, on a record that carries an end, '
|
|
2358
|
+
+ '--from <a start its directory recorded> --attest <commit> [--attest <commit> ...] [--raw].';
|
|
2359
|
+
const positionals = [];
|
|
2360
|
+
const shas = {};
|
|
2361
|
+
const attest = [];
|
|
2362
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
2363
|
+
const a = rest[i];
|
|
2364
|
+
if (a === '--raw')
|
|
2365
|
+
continue;
|
|
2366
|
+
if (a === '--at' || a === '--from' || a === '--attest') {
|
|
2367
|
+
const v = rest[i + 1];
|
|
2368
|
+
if (v === undefined || v.startsWith('--')) {
|
|
2369
|
+
error(`${a} needs a commit. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
2370
|
+
return;
|
|
2371
|
+
}
|
|
2372
|
+
if (a === '--attest')
|
|
2373
|
+
attest.push(v);
|
|
2374
|
+
else
|
|
2375
|
+
shas[a] = v;
|
|
2376
|
+
i += 1;
|
|
2377
|
+
continue;
|
|
2378
|
+
}
|
|
2379
|
+
if (a.startsWith('--')) {
|
|
2380
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault anchor backfill. Supported: --at <commit>, --from <commit>, --attest <commit>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2381
|
+
return;
|
|
2382
|
+
}
|
|
2383
|
+
positionals.push(a);
|
|
2384
|
+
}
|
|
2385
|
+
if (positionals.length !== 1) {
|
|
2386
|
+
error(`vault anchor backfill takes exactly one SUMMARY path. ${usage}`, positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
if (shas['--at'] === undefined && shas['--from'] === undefined) {
|
|
2390
|
+
error(`vault anchor backfill needs --at <commit>: there is no silent restamp at HEAD; name the commit the record's work ended at. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
2391
|
+
return;
|
|
2392
|
+
}
|
|
2393
|
+
// 16 review iteration 3 CR-01 (D-102): nothing widens a record's start any
|
|
2394
|
+
// more. `--at` gives a record with no anchor its END (its start is its
|
|
2395
|
+
// directory's, D-59); `--from` only bounds `--attest`, which names each
|
|
2396
|
+
// commit that counts as covered, one by one.
|
|
2397
|
+
if (shas['--at'] !== undefined && (shas['--from'] !== undefined || attest.length > 0)) {
|
|
2398
|
+
error(`--at gives a record its end and nothing else; --from and --attest are for a record that already carries one (they attest single commits, they never widen its range). ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
if (shas['--at'] === undefined && (shas['--from'] === undefined || attest.length === 0)) {
|
|
2402
|
+
error(`${shas['--from'] === undefined ? '--attest needs --from <a start the record\'s directory recorded>' : '--from needs at least one --attest <commit>: each commit is attested by its own full id, and nothing else is covered'}. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
2403
|
+
return;
|
|
2404
|
+
}
|
|
2405
|
+
for (const [flag, value] of [['--at', shas['--at']], ['--from', shas['--from']], ...attest.map((v) => ['--attest', v])]) {
|
|
2406
|
+
if (value !== undefined && !vaultSeal.ANCHOR_SHA_RE.test(value)) {
|
|
2407
|
+
error(`${flag} is not a full lowercase commit id (40 or 64 hex). ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2408
|
+
return;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
const located = resolvePlanningPath(cwd, positionals[0], 'file');
|
|
2412
|
+
if (!located.ok) {
|
|
2413
|
+
error(`The SUMMARY path ${located.message}; a back-fill rewrites only a record under .planning/.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2414
|
+
return;
|
|
2415
|
+
}
|
|
2416
|
+
const at = shas['--at'];
|
|
2417
|
+
const from = shas['--from'];
|
|
2418
|
+
const emit = (result, text) => {
|
|
2419
|
+
c.output(result, true, raw ? JSON.stringify(result) : text);
|
|
2420
|
+
};
|
|
2421
|
+
const refuse = (reason, message) => {
|
|
2422
|
+
emit({ ok: false, action: 'backfill', file: located.rel, reason, message }, `No anchor was back-filled: ${message}.`);
|
|
2423
|
+
};
|
|
2424
|
+
const size = node_fs_1.default.statSync(located.abs).size;
|
|
2425
|
+
if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
|
|
2426
|
+
refuse('too-large', `${located.rel} is ${size} bytes, above the ${vaultSeal.TRACE_ARTIFACT_BYTE_CAP}-byte read cap the record readers apply`);
|
|
728
2427
|
return;
|
|
729
2428
|
}
|
|
2429
|
+
const text = node_fs_1.default.readFileSync(located.abs, 'utf8');
|
|
730
2430
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
731
|
-
const
|
|
732
|
-
const
|
|
733
|
-
const
|
|
734
|
-
const
|
|
735
|
-
|
|
736
|
-
const
|
|
737
|
-
const
|
|
738
|
-
if (
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
2431
|
+
const traceLeaf = require('./vault-trace.cjs');
|
|
2432
|
+
const ledger = traceLeaf.parseVaultTraceBlock(text, located.rel);
|
|
2433
|
+
const st = (ledger.present ? ledger.statement : null);
|
|
2434
|
+
const isLedger = st !== null && st._type === traceLeaf.TRACE_STATEMENT_TYPE && st.predicateType === traceLeaf.TRACE_PREDICATE_TYPE
|
|
2435
|
+
&& st.predicate !== undefined && st.predicate.stage === 'execute';
|
|
2436
|
+
const section = isLedger ? null : vaultStandardsLeaf.parseConsultedSection(text);
|
|
2437
|
+
const isSection = section !== null && section.present && section.planId !== null;
|
|
2438
|
+
if (!isLedger && !isSection) {
|
|
2439
|
+
refuse('no-record', `${located.rel} carries no execute-stage vault: ledger and no ${vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING} section to back-fill`);
|
|
2440
|
+
return;
|
|
2441
|
+
}
|
|
2442
|
+
const anchored = isLedger
|
|
2443
|
+
? ['startCommit', 'endCommit', 'anchorBackfill', 'anchorsDropped'].some((k) => k in st.predicate)
|
|
2444
|
+
: section.anchors !== undefined
|
|
2445
|
+
|| section.problems.some((p) => p.kind === vaultStandardsLeaf.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED);
|
|
2446
|
+
if (at === undefined) {
|
|
2447
|
+
// `--from` + `--attest`: the attestation (16 review iteration 3 CR-01, D-102).
|
|
2448
|
+
if (!anchored) {
|
|
2449
|
+
refuse('at-required', `${located.rel} carries no anchor yet; give it its end with --at <commit> (--from and --attest attest single commits on a record that already carries an end)`);
|
|
2450
|
+
return;
|
|
2451
|
+
}
|
|
2452
|
+
attestVerb({
|
|
2453
|
+
cwd, exec, text, located, from, attest, isLedger, refuse, emit, traceLeaf,
|
|
748
2454
|
});
|
|
749
|
-
node_fs_1.default.writeFileSync(abs, text, 'utf-8');
|
|
750
|
-
const toJudge = Array.isArray(partition.remainder) ? partition.remainder.length : 0;
|
|
751
|
-
const derived = (Array.isArray(partition.naUniverse) ? partition.naUniverse.length : 0) +
|
|
752
|
-
(Array.isArray(partition.naNoFile) ? partition.naNoFile.length : 0);
|
|
753
|
-
const statement = `Vault review: wrote ${file} (arm: ${String(payload.arm)}; ${toJudge} practice(s) to judge, ` +
|
|
754
|
-
`${derived} derived N-A${payload.refusesCleanRender === true ? '; no clean review may be rendered from it' : ''}).`;
|
|
755
|
-
say({ status: 'written', file, arm: payload.arm, toJudge, derived, refusesCleanRender: payload.refusesCleanRender === true, statement }, statement);
|
|
756
2455
|
return;
|
|
757
2456
|
}
|
|
758
|
-
|
|
759
|
-
|
|
2457
|
+
if (anchored) {
|
|
2458
|
+
refuse('already-anchored', `${located.rel} already carries an anchor; a back-fill only gives anchors to a record that never had one (a commit its range misses is attested with --from and --attest)`);
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
// The shas reach git only now, shape-checked, after the subcommand (T-16-21).
|
|
2462
|
+
if (exec(['merge-base', '--is-ancestor', at, 'HEAD'], { cwd }).exitCode !== 0) {
|
|
2463
|
+
refuse('at-not-ancestor', `--at ${at} is not an ancestor of HEAD (or is not in this repository)`);
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
const backfill = { by: traceLeaf.resolveBackfillIdentity(cwd), at: new Date().toISOString() };
|
|
2467
|
+
const spans = lineSpans(text);
|
|
2468
|
+
let next;
|
|
2469
|
+
if (isLedger) {
|
|
2470
|
+
const where = ledgerGeneratedAtLine(text, spans);
|
|
2471
|
+
if (where === null) {
|
|
2472
|
+
refuse('rewrite-unverifiable', `the ledger in ${located.rel} has no generatedAt line where the anchors belong`);
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
// The serializer's lines, re-indented to this ledger's own indentation.
|
|
2476
|
+
const lines = traceLeaf.traceAnchorLines({ ...(from !== undefined ? { startCommit: from } : {}), endCommit: at, anchorBackfill: backfill })
|
|
2477
|
+
.map((l) => {
|
|
2478
|
+
const depth = (l.length - l.trimStart().length) / 2 - 2;
|
|
2479
|
+
return `${' '.repeat(where.keyIndent + depth * where.step)}${l.trimStart()}`;
|
|
2480
|
+
});
|
|
2481
|
+
next = insertLinesAfter(text, spans, where.index, lines);
|
|
2482
|
+
const back = traceLeaf.parseVaultTraceBlock(next, located.rel);
|
|
2483
|
+
const bp = back.present ? back.statement.predicate : {};
|
|
2484
|
+
const agrees = back.present
|
|
2485
|
+
&& bp.endCommit === at
|
|
2486
|
+
&& bp.startCommit === from
|
|
2487
|
+
&& JSON.stringify(bp.anchorBackfill) === JSON.stringify(backfill)
|
|
2488
|
+
&& !('anchorsDropped' in bp)
|
|
2489
|
+
&& statementWithoutAnchors(back.statement) === statementWithoutAnchors(st);
|
|
2490
|
+
if (!agrees) {
|
|
2491
|
+
refuse('rewrite-unverifiable', `the back-filled ledger in ${located.rel} would not read back as written, so nothing was written`);
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
else {
|
|
2496
|
+
const live = vaultStandardsLeaf.findLiveSection(text, vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING);
|
|
2497
|
+
const lastBody = live.body[live.body.length - 1];
|
|
2498
|
+
const line = vaultStandardsLeaf.formatStandardsAnchorLine({ start: from ?? null, end: at, backfill });
|
|
2499
|
+
next = insertLinesAfter(text, spans, lastBody, [line]);
|
|
2500
|
+
const back = vaultStandardsLeaf.parseConsultedSection(next);
|
|
2501
|
+
const before = section;
|
|
2502
|
+
const expected = { ...(from !== undefined ? { start: from } : {}), end: at, backfill };
|
|
2503
|
+
const strip = (p) => JSON.stringify({ ...p, anchors: undefined, headingLine: undefined });
|
|
2504
|
+
const agrees = back.present
|
|
2505
|
+
&& JSON.stringify(back.anchors) === JSON.stringify({ end: expected.end, ...(from !== undefined ? { start: from } : {}), backfill })
|
|
2506
|
+
&& strip(back) === strip(before);
|
|
2507
|
+
if (!agrees) {
|
|
2508
|
+
refuse('rewrite-unverifiable', `the back-filled section in ${located.rel} would not read back as written, so nothing was written`);
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
const tmp = `${located.abs}.${process.pid}.tmp`;
|
|
760
2513
|
try {
|
|
761
|
-
|
|
2514
|
+
node_fs_1.default.writeFileSync(tmp, next, 'utf8');
|
|
2515
|
+
node_fs_1.default.renameSync(tmp, located.abs);
|
|
762
2516
|
}
|
|
763
2517
|
catch {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
const result =
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
2518
|
+
try {
|
|
2519
|
+
node_fs_1.default.unlinkSync(tmp);
|
|
2520
|
+
}
|
|
2521
|
+
catch { /* nothing to clean */ }
|
|
2522
|
+
refuse('write-failed', `${located.rel} could not be written`);
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
const result = {
|
|
2526
|
+
ok: true,
|
|
2527
|
+
action: 'backfill',
|
|
2528
|
+
file: located.rel,
|
|
2529
|
+
family: isLedger ? 'practices' : 'standards',
|
|
2530
|
+
endCommit: at,
|
|
2531
|
+
...(from !== undefined ? { startCommit: from } : {}),
|
|
2532
|
+
backfilledBy: backfill.by,
|
|
2533
|
+
backfilledAt: backfill.at,
|
|
779
2534
|
};
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
2535
|
+
emit(result, `Back-filled ${located.rel}: end ${at}${from !== undefined ? `, start ${from}` : ''}, by ${backfill.by} at ${backfill.at}.`);
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* The attestation (16 review iteration 3 CR-01, D-102; replaces D-98's start
|
|
2539
|
+
* back-fill): `vault anchor backfill <summary> --from <start> --attest <commit>
|
|
2540
|
+
* [--attest <commit> ...]` on a record that already carries an end.
|
|
2541
|
+
*
|
|
2542
|
+
* D-98 records a new directory start on every resume, so the phase's own
|
|
2543
|
+
* commits made between its last record and the resume are covered by nothing,
|
|
2544
|
+
* and the strict gate names them. Whether such a commit is the phase's work or
|
|
2545
|
+
* an ungoverned one is not something the engine can tell. D-98's repair widened
|
|
2546
|
+
* the record's START, which covered every commit in between — an ungoverned one
|
|
2547
|
+
* made by someone else between the runs included — and left a note naming none
|
|
2548
|
+
* of them. An attestation widens nothing: each `--attest` names ONE commit, and
|
|
2549
|
+
* that commit alone counts as covered by the record. Every later strict check
|
|
2550
|
+
* lists it, with its subject and paths and who attested it. A commit nobody
|
|
2551
|
+
* attested stays named, and stays a block.
|
|
2552
|
+
*
|
|
2553
|
+
* Refused, writing nothing (each an `ok: false` envelope):
|
|
2554
|
+
* - a record with no valid end (`at-required`, `no-end`) or a malformed anchor;
|
|
2555
|
+
* - `--from` that is not a start the record's own directory recorded
|
|
2556
|
+
* (`vault anchor start` writes them, D-98 keeps the superseded ones), so no
|
|
2557
|
+
* attestation reaches below where the CLI saw this phase begin
|
|
2558
|
+
* (`from-not-recorded`), or that is not an ancestor of the record's end;
|
|
2559
|
+
* - an `--attest` that is not on the record's own (first-parent) line between
|
|
2560
|
+
* `--from` and its end, a merge commit, a commit touching no source path, one
|
|
2561
|
+
* the record already covers, one already attested on it, or given twice.
|
|
2562
|
+
*
|
|
2563
|
+
* The attestations are written to `<dir>/.vault-attestations.json` (the seal
|
|
2564
|
+
* leaf's `appendDirAttestations`), never into the record: the record's bytes,
|
|
2565
|
+
* and the range they state, do not change. `by` is the git `user.name` of
|
|
2566
|
+
* whoever ran the command — what the CLI can read, not a verified identity —
|
|
2567
|
+
* and the envelope says so (`byVerified: false`).
|
|
2568
|
+
*/
|
|
2569
|
+
function attestVerb({ cwd, exec, text, located, from, attest, isLedger, refuse, emit, traceLeaf }) {
|
|
2570
|
+
const dirAbs = node_path_1.default.dirname(located.abs);
|
|
2571
|
+
const recordName = node_path_1.default.basename(located.abs);
|
|
2572
|
+
const kind = isLedger ? vaultSeal.RECORDED_ARTIFACT_KIND.SUMMARY_LEDGER : vaultSeal.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED;
|
|
2573
|
+
const isAncestor = (a, d) => vaultSeal.isAnchorSha(a) && vaultSeal.isAnchorSha(d)
|
|
2574
|
+
&& exec(['merge-base', '--is-ancestor', a, d], { cwd }).exitCode === 0;
|
|
2575
|
+
const anchors = vaultSeal.resolveRecordAnchors(text, kind, dirAbs, { isAncestor });
|
|
2576
|
+
if (anchors.problems.some((p) => p.kind === 'anchor_invalid')) {
|
|
2577
|
+
refuse('anchors-malformed', `an anchor of ${located.rel} is malformed or was dropped; restore the value the CLI wrote before attesting a commit on it`);
|
|
2578
|
+
return;
|
|
2579
|
+
}
|
|
2580
|
+
const end = anchors.end;
|
|
2581
|
+
if (end === undefined) {
|
|
2582
|
+
refuse('no-end', `${located.rel} carries no valid end anchor; give it one with --at <commit> first`);
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
const dir = vaultSeal.readDirAnchors(dirAbs);
|
|
2586
|
+
const recorded = dir === null ? [] : [dir.start, ...(dir.earlier ?? []).map((e) => e.start)];
|
|
2587
|
+
if (!recorded.includes(from)) {
|
|
2588
|
+
refuse('from-not-recorded', `--from ${from} is not a start this record's directory recorded (${recorded.length} recorded, by vault anchor start); `
|
|
2589
|
+
+ 'an attestation reaches back only to a start the CLI recorded for this phase, never to an arbitrary commit');
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
if (from === end || !isAncestor(from, end)) {
|
|
2593
|
+
refuse('from-not-ancestor', `--from ${from} is not an ancestor of the record's end ${end}`);
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
const cap = vaultSeal.SEAL_CANDIDATE_CAP;
|
|
2597
|
+
const lineRun = exec(['rev-list', '--first-parent', '--parents', `--max-count=${cap + 1}`, `^${from}`, end], { cwd });
|
|
2598
|
+
const rows = lineRun.exitCode === 0 ? lineRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/\s+/)) : null;
|
|
2599
|
+
if (rows === null || !rows.every((r) => r.every((x) => vaultSeal.isAnchorSha(x)))) {
|
|
2600
|
+
refuse('git-failed', 'the commits between --from and the record\'s end could not be listed');
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
if (rows.length > cap) {
|
|
2604
|
+
refuse('too-many', `more than ${cap} commits lie between --from and the record's end`);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
const onLine = new Map(rows.map((r) => [r[0], r.length - 1]));
|
|
2608
|
+
let own = new Set();
|
|
2609
|
+
if (anchors.start !== undefined && isAncestor(anchors.start, end)) {
|
|
2610
|
+
const ownRun = exec(['rev-list', '--first-parent', `--max-count=${cap + 1}`, `^${anchors.start}`, end], { cwd });
|
|
2611
|
+
if (ownRun.exitCode !== 0) {
|
|
2612
|
+
refuse('git-failed', 'the commits the record already covers could not be listed');
|
|
2613
|
+
return;
|
|
2614
|
+
}
|
|
2615
|
+
own = new Set(ownRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
|
|
2616
|
+
}
|
|
2617
|
+
const already = new Set(vaultSeal.attestedFor(vaultSeal.readDirAttestations(dirAbs), recordName).map((a) => a.sha));
|
|
2618
|
+
const by = traceLeaf.resolveBackfillIdentity(cwd);
|
|
2619
|
+
const at = new Date().toISOString();
|
|
2620
|
+
const entries = [];
|
|
2621
|
+
for (const sha of attest) {
|
|
2622
|
+
if (entries.some((e) => e.sha === sha)) {
|
|
2623
|
+
refuse('attest-duplicate', `--attest ${sha} was given twice`);
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
if (!onLine.has(sha)) {
|
|
2627
|
+
refuse('attest-not-in-range', `--attest ${sha} is not on ${located.rel}'s own line between --from ${from} and its end ${end}`);
|
|
2628
|
+
return;
|
|
2629
|
+
}
|
|
2630
|
+
if (onLine.get(sha) > 1) {
|
|
2631
|
+
refuse('attest-merge', `--attest ${sha} is a merge commit; attest the commits it brought in, each by its own id`);
|
|
2632
|
+
return;
|
|
2633
|
+
}
|
|
2634
|
+
if (own.has(sha)) {
|
|
2635
|
+
refuse('attest-already-covered', `--attest ${sha} is already inside ${located.rel}'s own range; it needs no attestation`);
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
if (already.has(sha)) {
|
|
2639
|
+
refuse('attest-already-attested', `--attest ${sha} is already attested on ${located.rel}`);
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const tree = exec(['diff-tree', '-r', '-z', '--root', '--no-commit-id', '--name-only', '--no-renames', sha], { cwd });
|
|
2643
|
+
const subj = exec(['log', '-1', '--format=%s', sha], { cwd });
|
|
2644
|
+
if (tree.exitCode !== 0 || subj.exitCode !== 0) {
|
|
2645
|
+
refuse('git-failed', `the paths or subject of ${sha} could not be read`);
|
|
2646
|
+
return;
|
|
2647
|
+
}
|
|
2648
|
+
const paths = tree.stdout.split('\0').filter((p) => p !== '' && !vaultSeal.isNonSourcePath(p));
|
|
2649
|
+
if (paths.length === 0) {
|
|
2650
|
+
refuse('attest-not-source', `--attest ${sha} touches no governed source path, so no check names it and there is nothing to attest`);
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const flat = (v, n) => {
|
|
2654
|
+
const f = v.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
2655
|
+
return f.length > n ? `${f.slice(0, n)}…` : f;
|
|
2656
|
+
};
|
|
2657
|
+
entries.push({
|
|
2658
|
+
record: recordName,
|
|
2659
|
+
from,
|
|
2660
|
+
sha,
|
|
2661
|
+
subject: flat(subj.stdout, vaultSeal.SEAL_SUBJECT_CAP),
|
|
2662
|
+
paths: paths.slice(0, vaultSeal.ATTESTATION_PATHS_CAP).map((p) => flat(p, 1000)),
|
|
2663
|
+
by,
|
|
2664
|
+
at,
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
try {
|
|
2668
|
+
vaultSeal.appendDirAttestations(dirAbs, entries);
|
|
2669
|
+
}
|
|
2670
|
+
catch (e) {
|
|
2671
|
+
refuse('write-failed', `${vaultSeal.VAULT_ATTESTATIONS_FILE} next to ${located.rel} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
|
|
2672
|
+
return;
|
|
2673
|
+
}
|
|
2674
|
+
const listed = entries.map((e) => ({ sha: e.sha, subject: e.subject, paths: e.paths }));
|
|
2675
|
+
emit({
|
|
2676
|
+
ok: true,
|
|
2677
|
+
action: 'attest',
|
|
2678
|
+
file: located.rel,
|
|
2679
|
+
family: isLedger ? 'practices' : 'standards',
|
|
2680
|
+
from,
|
|
2681
|
+
endCommit: end,
|
|
2682
|
+
attested: listed,
|
|
2683
|
+
attestedBy: by,
|
|
2684
|
+
byVerified: false,
|
|
2685
|
+
attestedAt: at,
|
|
2686
|
+
}, `Attested ${entries.length} commit${entries.length === 1 ? '' : 's'} on ${located.rel}, by ${by} (the git user.name, not verified) at ${at}: `
|
|
2687
|
+
+ `${entries.map((e) => `${e.sha.slice(0, 12)} "${e.subject}" (${e.paths.join(', ')})`).join('; ')}. Every later strict check lists each one.`);
|
|
2688
|
+
}
|
|
2689
|
+
/** The longest free-text value a door record line carries (the reason-clause bound, with room). */
|
|
2690
|
+
const DOOR_TEXT_MAX = 190;
|
|
2691
|
+
/**
|
|
2692
|
+
* A free-text value made safe for a door record line: the vault leaf's display
|
|
2693
|
+
* sanitiser (controls, bidi, zero-width, fence markers), backticks turned into
|
|
2694
|
+
* apostrophes, bounded. Empty or the literal `none` becomes `unknown`, because
|
|
2695
|
+
* `none` is how a door record spells an absent value.
|
|
2696
|
+
*/
|
|
2697
|
+
function doorText(value, opts = {}) {
|
|
2698
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
2699
|
+
const indexLeaf = require('./vault-index.cjs');
|
|
2700
|
+
let t = indexLeaf.sanitiseVaultText(typeof value === 'string' ? value : '', DOOR_TEXT_MAX).replace(/`/g, "'").trim();
|
|
2701
|
+
if (opts.token === true)
|
|
2702
|
+
t = t.replace(/\s+/g, '_');
|
|
2703
|
+
return t === '' || t === 'none' ? 'unknown' : t;
|
|
2704
|
+
}
|
|
2705
|
+
/**
|
|
2706
|
+
* The vault coordinates a door worked under, or the degraded one-liner saying
|
|
2707
|
+
* why there are none (D-12 "or") — read by the SAME pin reader the mode's own
|
|
2708
|
+
* record verb uses, never a third reader of the manifest:
|
|
2709
|
+
*
|
|
2710
|
+
* - practices: the trace scaffold's `resolvePin` over the one resolved cache.
|
|
2711
|
+
* Only a `loaded` pin is recorded as coordinates; a degraded run (stale,
|
|
2712
|
+
* fallback tier, ref drift, miss, no cache) records its reason instead,
|
|
2713
|
+
* the same distinction the practices ledger draws;
|
|
2714
|
+
* - standards: `standardsLoadedPin` over the manifest of the one resolved
|
|
2715
|
+
* cache, the block verb's reader; no loaded pin records the degraded line
|
|
2716
|
+
* `vault standards block` would write for it.
|
|
2717
|
+
*
|
|
2718
|
+
* The repo has already been through `redactCloneTarget` (both readers go
|
|
2719
|
+
* through `readManifest`), so no credential reaches the record.
|
|
2720
|
+
*/
|
|
2721
|
+
function doorVaultPin(cwd, mode, owner) {
|
|
2722
|
+
const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
|
|
2723
|
+
if (mode === 'practices') {
|
|
2724
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
2725
|
+
const traceLeaf = require('./vault-trace.cjs');
|
|
2726
|
+
const pin = traceLeaf.resolvePin(cwd, cacheDir);
|
|
2727
|
+
if (pin.state === 'loaded' && typeof pin.ref === 'string' && vaultSeal.ANCHOR_SHA_RE.test(pin.resolvedSha ?? '')) {
|
|
2728
|
+
return { vault: { repo: doorText(pin.repo, { token: true }), ref: doorText(pin.ref, { token: true }), sha: pin.resolvedSha }, degraded: null };
|
|
2729
|
+
}
|
|
2730
|
+
return { vault: null, degraded: doorText(pin.reason ?? 'the vault was not loaded') };
|
|
2731
|
+
}
|
|
2732
|
+
const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
|
|
2733
|
+
if (pin !== null && vaultSeal.ANCHOR_SHA_RE.test(pin.sha)) {
|
|
2734
|
+
return { vault: { repo: doorText(pin.repo, { token: true }), ref: doorText(pin.ref, { token: true }), sha: pin.sha }, degraded: null };
|
|
2735
|
+
}
|
|
2736
|
+
const line = vaultStandardsLeaf.standardsDegradedLineFor('not-loaded');
|
|
2737
|
+
return { vault: null, degraded: doorText(`${line.kind}: ${line.reason}`) };
|
|
2738
|
+
}
|
|
2739
|
+
/**
|
|
2740
|
+
* Where a door artifact may live: a phase directory (root or workstream), a
|
|
2741
|
+
* quick-task directory, or `.planning/audits`. Those are exactly the
|
|
2742
|
+
* directories the seal readers walk (`hasRecordedVaultArtifacts`, 16-07's
|
|
2743
|
+
* covering-record walk), so a record written anywhere else would be a record
|
|
2744
|
+
* no reader ever finds: the door would believe it was recorded and nothing
|
|
2745
|
+
* would see it (D-55).
|
|
2746
|
+
*/
|
|
2747
|
+
function isDoorArtifactLocation(rel) {
|
|
2748
|
+
const parts = rel.split('/');
|
|
2749
|
+
if (parts[0] !== '.planning')
|
|
2750
|
+
return false;
|
|
2751
|
+
if (parts.length === 4 && (parts[1] === 'phases' || parts[1] === 'quick'))
|
|
2752
|
+
return true;
|
|
2753
|
+
if (parts.length === 6 && parts[1] === 'workstreams' && parts[3] === 'phases')
|
|
2754
|
+
return true;
|
|
2755
|
+
return parts.length === 3 && parts[1] === 'audits';
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* `vault door-record --begin --door <name>` (16 review CR-01): record HEAD as
|
|
2759
|
+
* the door's START in `.planning/.vault-door-starts.json` (the seal leaf's
|
|
2760
|
+
* `writeDoorStart`), before the door's agent runs. The record step then reads
|
|
2761
|
+
* it, so the start survives the agent's fresh shell per step and is never
|
|
2762
|
+
* typed. Called after admission (`on`) and the door-name check; takes no other
|
|
2763
|
+
* flag. Every refusal is an error (exit 1), like the record itself: a door that
|
|
2764
|
+
* cannot capture its start halts before its agent writes anything.
|
|
2765
|
+
*/
|
|
2766
|
+
function doorBegin({ cwd, door, extra, raw, error, c, exec, usage }) {
|
|
2767
|
+
if (extra.length > 0) {
|
|
2768
|
+
error(`--begin takes only --door; ${extra.join(', ')} belong to the record step. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2769
|
+
return;
|
|
2770
|
+
}
|
|
2771
|
+
const root = projectRootOf(cwd);
|
|
2772
|
+
const planning = node_path_1.default.join(root, '.planning');
|
|
2773
|
+
try {
|
|
2774
|
+
if (!node_fs_1.default.statSync(planning).isDirectory())
|
|
2775
|
+
throw new Error('not a directory');
|
|
2776
|
+
}
|
|
2777
|
+
catch {
|
|
2778
|
+
error('The project has no .planning/ directory, so the door start has nowhere to be recorded.', ERROR_REASON.SDK_FAIL_FAST);
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
const head = exec(['rev-parse', 'HEAD'], { cwd });
|
|
2782
|
+
const sha = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
2783
|
+
if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
|
|
2784
|
+
error('HEAD could not be read as a full commit id (no commit yet, or git failed), so no door start was recorded.', ERROR_REASON.SDK_FAIL_FAST);
|
|
2785
|
+
return;
|
|
2786
|
+
}
|
|
2787
|
+
let recorded;
|
|
2788
|
+
try {
|
|
2789
|
+
recorded = vaultSeal.writeDoorStart(planning, door, sha);
|
|
2790
|
+
}
|
|
2791
|
+
catch {
|
|
2792
|
+
error(`.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE} could not be written, so no door start was recorded.`, ERROR_REASON.UNKNOWN);
|
|
2793
|
+
return;
|
|
2794
|
+
}
|
|
2795
|
+
const file = `.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE}`;
|
|
2796
|
+
const result = { ok: true, action: 'begin', door, start: recorded.start, recordedAt: recorded.recordedAt, file };
|
|
2797
|
+
c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door's start ${recorded.start.slice(0, 12)} in ${file}.`);
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* `vault door-record --door <name> --artifact <path> [--phase <N|dir>]`
|
|
2801
|
+
* (16-06, D-12), after `vault door-record --begin --door <name>` (16 review
|
|
2802
|
+
* CR-01): the ONE verb that writes a door's record, a mode-stamped
|
|
2803
|
+
* `## Vault Door Record` section (`formatDoorRecord`, the seal leaf's grammar)
|
|
2804
|
+
* appended to the door's own artifact.
|
|
2805
|
+
*
|
|
2806
|
+
* The record carries the mode, the vault ref and sha (or the degraded
|
|
2807
|
+
* one-liner), the governing phase, the START commit the door captured before
|
|
2808
|
+
* its agent ran, the END commit (HEAD now) and when it was written. The start
|
|
2809
|
+
* is the one the door's `--begin` recorded in `.planning/`, and nothing else
|
|
2810
|
+
* (16 review iteration 3 CR-02, D-103: an explicit `--start` was a side door
|
|
2811
|
+
* that widened a record to any ancestor of HEAD with no note); nothing in the
|
|
2812
|
+
* record is typed by an agent (the
|
|
2813
|
+
* Phase 15 D-06 lesson), and no shell variable has to survive between the
|
|
2814
|
+
* door's steps.
|
|
2815
|
+
*
|
|
2816
|
+
* ORDER: admission first and silent (D-13: no output, no file, unless the
|
|
2817
|
+
* producer admission verdict answers `on` — the one every vault record producer
|
|
2818
|
+
* asks, quick 261015-ect QD-10: the cwd's own verdict except in a linked
|
|
2819
|
+
* worktree with no bindings of its own, where it is the owning checkout's; the
|
|
2820
|
+
* door fences probe the cwd's bindings first and never reach here there), then
|
|
2821
|
+
* the arguments, the door name, the artifact
|
|
2822
|
+
* (containment ladder, `.planning/` only, a seal-walked directory, a door
|
|
2823
|
+
* artifact filename), the start (anchor shape BEFORE it reaches git, then an
|
|
2824
|
+
* ancestor of HEAD, with the revision after the subcommand, T-16-21), the
|
|
2825
|
+
* phase, HEAD, the pin.
|
|
2826
|
+
*
|
|
2827
|
+
* APPEND, NEVER REWRITE: existing content is kept byte for byte and the section
|
|
2828
|
+
* goes after it; an absent artifact is created with a one-line header. An
|
|
2829
|
+
* artifact that already carries a door record is REFUSED rather than given a
|
|
2830
|
+
* second one, because two sections are a duplicate the reader rejects, which
|
|
2831
|
+
* would leave the door with no readable record at all. The composed text is
|
|
2832
|
+
* read back with `parseDoorRecord` before anything is written, so the verb can
|
|
2833
|
+
* never write a record its own reader refuses (an unclosed fence above the
|
|
2834
|
+
* section, say), and the write is a temporary sibling renamed into place.
|
|
2835
|
+
*
|
|
2836
|
+
* Every refusal is an error (exit 1): a door that cannot record halts its
|
|
2837
|
+
* fence instead of passing unrecorded.
|
|
2838
|
+
*/
|
|
2839
|
+
function doorRecordVerb({ cwd, rest, raw, error, c, exec }) {
|
|
2840
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
2841
|
+
const owner = require('./vault.cjs');
|
|
2842
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
2843
|
+
return;
|
|
2844
|
+
const usage = 'Usage: vault door-record --begin --door <name> [--raw], then vault door-record --door <name> --artifact <path under .planning/> [--phase <N|dir>] [--raw].';
|
|
2845
|
+
const values = {};
|
|
2846
|
+
let begin = false;
|
|
2847
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
2848
|
+
const a = rest[i];
|
|
2849
|
+
if (a === '--raw')
|
|
2850
|
+
continue;
|
|
2851
|
+
if (a === '--begin') {
|
|
2852
|
+
begin = true;
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2855
|
+
if (DOOR_RECORD_FLAGS.includes(a)) {
|
|
2856
|
+
const v = rest[i + 1];
|
|
2857
|
+
if (v === undefined || v.startsWith('--')) {
|
|
2858
|
+
error(`${a} needs a value. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
if (values[a] !== undefined) {
|
|
2862
|
+
error(`${a} was given more than once. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
values[a] = v;
|
|
2866
|
+
i += 1;
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2869
|
+
error(`Unrecognized argument "${boundedPath(a)}" for vault door-record. Supported: --begin, ${DOOR_RECORD_FLAGS.map((f) => `${f} <value>`).join(', ')}, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2870
|
+
return;
|
|
2871
|
+
}
|
|
2872
|
+
if (values['--door'] === undefined) {
|
|
2873
|
+
error(`vault door-record needs --door. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
const door = values['--door'];
|
|
2877
|
+
if (!vaultSeal.DOOR_NAMES.includes(door)) {
|
|
2878
|
+
error(`"${boundedPath(door)}" is not a door that writes a record. Allowed doors: ${vaultSeal.DOOR_NAMES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
if (begin) {
|
|
2882
|
+
doorBegin({ cwd, door, extra: Object.keys(values).filter((k) => k !== '--door'), raw, error, c, exec, usage });
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
if (values['--artifact'] === undefined) {
|
|
2886
|
+
error(`vault door-record needs --artifact. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const located = resolvePlanningPath(cwd, values['--artifact'], 'artifact');
|
|
2890
|
+
if (!located.ok) {
|
|
2891
|
+
error(`The door artifact ${located.message}; a door record is written only into a door's own artifact under .planning/.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
if (!isDoorArtifactLocation(located.rel)) {
|
|
2895
|
+
error(`The door artifact "${boundedPath(located.rel)}" is not in a phase directory, a quick-task directory or .planning/audits, ` +
|
|
2896
|
+
'the directories the seal readers walk; a record anywhere else would never be read.', ERROR_REASON.SDK_FAIL_FAST);
|
|
2897
|
+
return;
|
|
2898
|
+
}
|
|
2899
|
+
// 16 review IN-02: the readers still recognise a `-VALIDATION.md` door
|
|
2900
|
+
// artifact (a legacy record stays readable), but the verb never WRITES one:
|
|
2901
|
+
// a `*-VALIDATION.md` file makes a phase read as validated (D-79 (3)).
|
|
2902
|
+
const artifactName = node_path_1.default.basename(located.abs);
|
|
2903
|
+
if (!vaultSeal.DOOR_ARTIFACT_FILENAME_RE.test(artifactName) || /(^|-)VALIDATION\.md$/.test(artifactName)) {
|
|
2904
|
+
error(`The door artifact "${boundedPath(artifactName)}" does not end in -REVIEW-FIX.md, -ADD-TESTS.md or -AUDIT-FIX.md, ` +
|
|
2905
|
+
'the door artifact names the verb writes (a -VALIDATION.md file would make the phase read as validated).', ERROR_REASON.SDK_FAIL_FAST);
|
|
2906
|
+
return;
|
|
2907
|
+
}
|
|
2908
|
+
// The start is the one this door's `--begin` recorded in `.planning/`, and
|
|
2909
|
+
// only that (16 review iteration 3 CR-02, D-103: `--start` is gone), so no
|
|
2910
|
+
// shell variable has to survive between the two steps, the agent never types
|
|
2911
|
+
// a sha, and no argument can move the start. It reaches git only after it has
|
|
2912
|
+
// the anchor shape, and only AFTER the subcommand and its flag (T-16-21).
|
|
2913
|
+
const recorded = vaultSeal.readDoorStart(node_path_1.default.join(located.root, '.planning'), door);
|
|
2914
|
+
if (recorded === null) {
|
|
2915
|
+
error(`No start was recorded for the ${door} door, so its range is unknown and the record was not written. `
|
|
2916
|
+
+ `Run \`pp-tools vault door-record --begin --door ${door}\` before the door's agent runs; a start recorded now covers only commits made after it.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const start = recorded.start;
|
|
2920
|
+
if (exec(['merge-base', '--is-ancestor', start, 'HEAD'], { cwd }).exitCode !== 0) {
|
|
2921
|
+
error(`The start the ${door} door's --begin recorded (${start}) is not an ancestor of HEAD (or is not in this repository), so it cannot be the commit this door started from. `
|
|
2922
|
+
+ `Run \`pp-tools vault door-record --begin --door ${door}\` again, then redo the door's work.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2923
|
+
return;
|
|
2924
|
+
}
|
|
2925
|
+
const headRun = exec(['rev-parse', 'HEAD'], { cwd });
|
|
2926
|
+
const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
|
|
2927
|
+
if (!vaultSeal.ANCHOR_SHA_RE.test(end)) {
|
|
2928
|
+
error('HEAD could not be read as a full commit id, so the record has no end commit and was not written.', ERROR_REASON.SDK_FAIL_FAST);
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
let phase = null;
|
|
2932
|
+
if (values['--phase'] !== undefined) {
|
|
2933
|
+
const resolved = owner.resolveTracePhaseDir(located.root, cwd, values['--phase']);
|
|
2934
|
+
if (!resolved) {
|
|
2935
|
+
error(`--phase "${boundedPath(values['--phase'])}" is neither a phase directory inside the project nor a phase the locator resolves.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
phase = doorText(resolved.rel, { token: true });
|
|
2939
|
+
}
|
|
2940
|
+
const modeVerdict = owner.vaultModeVerdict(cwd);
|
|
2941
|
+
const mode = modeVerdict.active && modeVerdict.ok === true && (modeVerdict.mode === 'practices' || modeVerdict.mode === 'standards')
|
|
2942
|
+
? modeVerdict.mode
|
|
2943
|
+
: null;
|
|
2944
|
+
if (mode === null) {
|
|
2945
|
+
error('The vault mode could not be resolved, so the record has no mode and was not written.', ERROR_REASON.SDK_FAIL_FAST);
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
const pin = doorVaultPin(cwd, mode, owner);
|
|
2949
|
+
let existing = '';
|
|
2950
|
+
if (node_fs_1.default.existsSync(located.abs)) {
|
|
2951
|
+
const size = node_fs_1.default.statSync(located.abs).size;
|
|
2952
|
+
if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
|
|
2953
|
+
error(`The door artifact is ${size} bytes, above the ${vaultSeal.TRACE_ARTIFACT_BYTE_CAP}-byte read cap the seal readers apply; a record there would never be read.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
existing = node_fs_1.default.readFileSync(located.abs, 'utf8');
|
|
2957
|
+
const prior = vaultSeal.parseDoorRecord(existing);
|
|
2958
|
+
if (!prior.ok || prior.record !== null) {
|
|
2959
|
+
error(`The door artifact "${boundedPath(located.rel)}" already carries a ${vaultSeal.DOOR_RECORD_HEADING} section; a second one would make both unreadable. ` +
|
|
2960
|
+
'Write the door\'s artifact afresh for this run, then record it.', ERROR_REASON.SDK_FAIL_FAST);
|
|
2961
|
+
return;
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
let section;
|
|
2965
|
+
try {
|
|
2966
|
+
section = vaultSeal.formatDoorRecord({
|
|
2967
|
+
door: door,
|
|
2968
|
+
mode,
|
|
2969
|
+
vault: pin.vault,
|
|
2970
|
+
degraded: pin.degraded,
|
|
2971
|
+
phase,
|
|
2972
|
+
startCommit: start,
|
|
2973
|
+
endCommit: end,
|
|
2974
|
+
recordedAt: new Date().toISOString(),
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
catch (e) {
|
|
2978
|
+
error(`The door record could not be composed: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
|
|
2979
|
+
return;
|
|
2980
|
+
}
|
|
2981
|
+
const separator = existing === '' ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
2982
|
+
const next = existing === '' ? `# ${door} door record\n\n${section}` : `${existing}${separator}${section}`;
|
|
2983
|
+
const readBack = vaultSeal.parseDoorRecord(next);
|
|
2984
|
+
if (!readBack.ok || readBack.record === null || readBack.record.startCommit !== start || readBack.record.endCommit !== end) {
|
|
2985
|
+
error(`The door record would not read back from "${boundedPath(located.rel)}" (an unclosed code fence or comment above it, for example), so nothing was written.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
// D-100 (16 review iteration 2 WR-01): consume the `--begin` start BEFORE the
|
|
2989
|
+
// record is written, after every refusal above. A start that cannot be
|
|
2990
|
+
// consumed refuses the record; a record whose write then fails leaves the
|
|
2991
|
+
// door with no start, so a retry halts naming `--begin` — both arms leave
|
|
2992
|
+
// commits uncovered (named by SEAL-01), never a range wider than one run.
|
|
2993
|
+
{
|
|
2994
|
+
let consumed = false;
|
|
2995
|
+
try {
|
|
2996
|
+
consumed = vaultSeal.consumeDoorStart(node_path_1.default.join(located.root, '.planning'), door, start);
|
|
2997
|
+
}
|
|
2998
|
+
catch {
|
|
2999
|
+
consumed = false;
|
|
3000
|
+
}
|
|
3001
|
+
if (!consumed) {
|
|
3002
|
+
error(`The ${door} door's start could not be consumed from .planning/${vaultSeal.VAULT_DOOR_STARTS_FILE} (it changed or could not be rewritten), so the record was not written. `
|
|
3003
|
+
+ `Run \`pp-tools vault door-record --begin --door ${door}\` again, then redo the door's work; a start recorded now covers only commits made after it.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
const tmp = `${located.abs}.${process.pid}.tmp`;
|
|
3008
|
+
try {
|
|
3009
|
+
node_fs_1.default.writeFileSync(tmp, next, 'utf8');
|
|
3010
|
+
node_fs_1.default.renameSync(tmp, located.abs);
|
|
3011
|
+
}
|
|
3012
|
+
catch {
|
|
3013
|
+
try {
|
|
3014
|
+
node_fs_1.default.unlinkSync(tmp);
|
|
3015
|
+
}
|
|
3016
|
+
catch { /* nothing to clean */ }
|
|
3017
|
+
error(`The door artifact "${boundedPath(located.rel)}" could not be written.`, ERROR_REASON.UNKNOWN);
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const result = { ok: true, door, artifact: located.rel, mode, phase, startCommit: start, endCommit: end };
|
|
3021
|
+
c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door in ${located.rel} (${start.slice(0, 12)}..${end.slice(0, 12)}).`);
|
|
793
3022
|
}
|
|
794
3023
|
module.exports = {
|
|
795
3024
|
routeVaultCommand,
|
|
3025
|
+
// 13-06: exported so the D-15 sweep test and the D-08 subcommand test read
|
|
3026
|
+
// the vocabulary from the running module instead of restating it.
|
|
796
3027
|
VAULT_SUBCOMMANDS,
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
3028
|
+
// 04-14: exported so the agreement and documentation tests read the SHAPE and
|
|
3029
|
+
// the WORDING from the running module rather than restating either. A test
|
|
3030
|
+
// that restated them would pass while the module drifted.
|
|
800
3031
|
SCAFFOLD_PLAN_ID_RE,
|
|
801
3032
|
SCAFFOLD_PLAN_ID_FORM,
|
|
802
|
-
|
|
3033
|
+
// 16-05 (D-29): the quick-task half both rejection messages append.
|
|
3034
|
+
SCAFFOLD_QUICK_ID_FORM,
|
|
3035
|
+
// 16-06: the anchor verbs, read by the tests from the running module.
|
|
3036
|
+
ANCHOR_VERBS,
|
|
3037
|
+
// 15-07: exported so the integration test pins it equal to the trace gate's
|
|
3038
|
+
// artifact read cap, which is the agreement its own comment states.
|
|
3039
|
+
STANDARDS_PLAN_READ_BYTES,
|
|
803
3040
|
};
|