@pareto4/pp-core 1.13.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +3 -3
- package/README.md +31 -12
- package/agents/pp-code-fixer.md +0 -11
- package/agents/pp-code-reviewer.md +1 -77
- package/agents/pp-debug-session-manager.md +9 -6
- package/agents/pp-executor.md +12 -29
- package/agents/pp-plan-checker.md +4 -4
- package/agents/pp-planner.md +34 -20
- package/agents/pp-vault-reviewer.md +100 -23
- package/agents/pp-verifier.md +1 -3
- package/bin/install.js +10 -3
- package/commands/pp-execute-phase.md +1 -3
- package/commands/pp-ns-manage.md +1 -1
- package/commands/pp-ns-review.md +1 -1
- package/commands/pp-plan-phase.md +1 -2
- package/commands/pp-vault-promote.md +7 -8
- package/commands/pp-vault-review.md +6 -5
- 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 +10 -18
- 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 +201 -229
- package/pp-core/bin/lib/capability-state.cjs +93 -1
- package/pp-core/bin/lib/capability-writer.cjs +3 -3
- package/pp-core/bin/lib/check-command-router.cjs +1906 -1514
- 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 +151 -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 +11 -12
- package/pp-core/bin/lib/git-provider.cjs +9 -10
- package/pp-core/bin/lib/init-command-router.cjs +5 -8
- package/pp-core/bin/lib/init-contract.cjs +620 -738
- package/pp-core/bin/lib/init-interactive.cjs +62 -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 +4 -2
- package/pp-core/bin/lib/loop-resolver.cjs +165 -129
- package/pp-core/bin/lib/org-profile.cjs +402 -161
- 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/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-command-router.cjs +1682 -477
- package/pp-core/bin/lib/vault-coordinates.cjs +16 -7
- package/pp-core/bin/lib/vault-index.cjs +656 -0
- package/pp-core/bin/lib/vault-seal.cjs +1851 -0
- package/pp-core/bin/lib/vault-standards-promote.cjs +577 -0
- package/pp-core/bin/lib/vault-standards-review.cjs +293 -0
- package/pp-core/bin/lib/vault-standards.cjs +1504 -0
- package/pp-core/bin/lib/vault-trace.cjs +155 -2778
- package/pp-core/bin/lib/vault-update.cjs +10 -59
- package/pp-core/bin/lib/vault.cjs +2570 -950
- 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 +157 -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-standards.md +311 -0
- package/pp-core/references/planning-config.md +0 -8
- package/pp-core/references/tdd.md +0 -1
- package/pp-core/templates/README.md +1 -0
- package/pp-core/templates/VAULT-REVIEW.md +56 -0
- package/pp-core/templates/phase-prompt.md +2 -6
- 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 -57
- 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 +207 -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 +327 -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 +78 -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 +219 -20
- package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +147 -0
- package/pp-core/workflows/execute-phase.md +152 -128
- package/pp-core/workflows/execute-plan.md +54 -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 +161 -23
- package/pp-core/workflows/vault-write-back.md +73 -110
- 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 +120 -13
- 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 +3 -19
- package/scripts/lint-test-file-count.cjs +5 -6
- package/scripts/prompt-injection-scan.sh +19 -17
- 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/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/bin/lib/practices-review-check.cjs +0 -938
- package/pp-core/bin/lib/vault-audit.cjs +0 -2070
- package/pp-core/bin/lib/vault-practices.cjs +0 -1990
- package/pp-core/bin/lib/vault-review.cjs +0 -2359
- package/pp-core/bin/lib/vault-write-back.cjs +0 -877
- package/pp-core/references/executor-vault-trace.md +0 -47
- package/pp-core/references/planner-plan-graph.md +0 -66
- package/pp-core/references/planner-vault-practices.md +0 -112
- package/pp-core/templates/practices/README.md +0 -251
- package/pp-core/templates/practices/error-handling.md +0 -18
- package/pp-core/templates/practices/minimal-comments.md +0 -14
- package/pp-core/templates/practices/naming-convention.md +0 -18
- package/pp-core/templates/practices/practice-template.md +0 -23
- 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,140 @@ 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): this router's path arms resolve their containment boundary the
|
|
43
|
+
// same 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 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
|
-
*
|
|
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
|
+
'org-profile',
|
|
72
|
+
// PBI 74316 (fork-only): move the project to another client vault version.
|
|
73
|
+
'update',
|
|
74
|
+
// 15-07 (D-06, D-13): the standards record verbs, `block` and `consulted`.
|
|
75
|
+
'standards',
|
|
76
|
+
// 16-04 (D-28): the one door-admission verb every door and loop-point fence asks.
|
|
77
|
+
'admission',
|
|
78
|
+
// 16-06 (D-31, D-32): the start anchor a phase or quick directory records at
|
|
79
|
+
// execute:pre, and the attributed back-fill of a record written before anchors.
|
|
80
|
+
'anchor',
|
|
81
|
+
// 16-06 (D-12): the one verb that writes a door's mode-stamped record.
|
|
82
|
+
'door-record',
|
|
83
|
+
];
|
|
84
|
+
/**
|
|
85
|
+
* The standards verbs (15-07), same discipline: the verb check and the message
|
|
86
|
+
* naming the permitted verbs both derive from this list. `promote` writes to
|
|
87
|
+
* the client vault rather than a record, and answers its own refusals.
|
|
51
88
|
*/
|
|
52
|
-
const
|
|
53
|
-
{ name: 'fetch', readsVerdict: false },
|
|
54
|
-
{ name: 'status', readsVerdict: true },
|
|
55
|
-
{ name: 'summary', readsVerdict: false },
|
|
56
|
-
{ name: 'practices', readsVerdict: false },
|
|
57
|
-
{ name: 'org-profile', readsVerdict: false },
|
|
58
|
-
{ name: 'probe', readsVerdict: false },
|
|
59
|
-
{ name: 'trace', readsVerdict: false },
|
|
60
|
-
{ name: 'audit', readsVerdict: false },
|
|
61
|
-
{ name: 'update', readsVerdict: false },
|
|
62
|
-
{ name: 'review', readsVerdict: false },
|
|
63
|
-
]);
|
|
89
|
+
const STANDARDS_VERBS = ['block', 'consulted', 'promote'];
|
|
64
90
|
/**
|
|
65
|
-
* The
|
|
66
|
-
*
|
|
67
|
-
* permitted values are all derived from these lists, so they cannot disagree.
|
|
68
|
-
* The values are the frozen contract's (`docs/vault-trace-format.md`).
|
|
91
|
+
* The anchor verbs (16-06), same discipline: the verb check and the message
|
|
92
|
+
* naming the permitted verbs both derive from this list.
|
|
69
93
|
*/
|
|
70
|
-
const
|
|
71
|
-
|
|
94
|
+
const ANCHOR_VERBS = ['start', 'backfill', 'certify'];
|
|
95
|
+
/** The flags `vault door-record` takes, declared once for the check and the message (16-06). */
|
|
96
|
+
// 16 review iteration 3 CR-02 (D-103): no `--start`. The start comes only from
|
|
97
|
+
// the door's own `--begin` entry, so no argument can widen a door's range.
|
|
98
|
+
const DOOR_RECORD_FLAGS = ['--door', '--artifact', '--phase'];
|
|
72
99
|
/**
|
|
73
|
-
* The
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
* trace gate comparing the recorded identifier with the filename can never be
|
|
79
|
-
* handed one the writer could not emit. It rejects what is not a plan
|
|
80
|
-
* identifier at all: a bare plan number (`01`), a task label (`T1`), a slug
|
|
81
|
-
* (`04-01-vault-trace`) or an empty string.
|
|
100
|
+
* The read cap `vault standards consulted` applies to a PLAN. EQUAL to the
|
|
101
|
+
* trace gate's `TRACE_ARTIFACT_BYTE_CAP` in src/check-command-router.cts, so
|
|
102
|
+
* the verb never refuses a plan the gate would read, nor reads one the gate
|
|
103
|
+
* refuses. That constant is not importable from here (an adapter never
|
|
104
|
+
* imports an adapter), so the integration test pins the two equal.
|
|
82
105
|
*/
|
|
83
|
-
const
|
|
106
|
+
const STANDARDS_PLAN_READ_BYTES = 1024 * 1024;
|
|
84
107
|
/**
|
|
85
|
-
* The
|
|
86
|
-
* `
|
|
87
|
-
*
|
|
108
|
+
* The plan-identifier shape `vault standards block --plan` accepts (04-14,
|
|
109
|
+
* TGATE-01, CR-03). Defined in `src/vault-standards.cts` since 15-05 and
|
|
110
|
+
* re-exported from here under the same name; the rationale stays here, beside
|
|
111
|
+
* the verb that uses it.
|
|
112
|
+
*
|
|
113
|
+
* ONE FORM, READ AND WRITTEN. `filenamePlanId` in `src/check-command-router.cts`
|
|
114
|
+
* extracts `<this form>-PLAN.md` / `-SUMMARY.md` from an artifact's basename,
|
|
115
|
+
* building its pattern from this very value, and blocks when the identifier it
|
|
116
|
+
* extracts disagrees with the one the record carries. So the writer can emit
|
|
117
|
+
* every identifier the reader can demand (the trap CR-03 reproduced, where
|
|
118
|
+
* `--plan 01` for `07-01-PLAN.md` blocked at all four points and the remedy
|
|
119
|
+
* re-ran the same command), and the reader checks every form the writer emits.
|
|
120
|
+
* Until 15-REVIEW WR-06 the reader stopped at digits-hyphen-digits, so a
|
|
121
|
+
* decimal (`02.3-01-PLAN.md`, bug #3691) or lettered phase stated no
|
|
122
|
+
* identifier and its plan-id check was silently skipped; the writer was
|
|
123
|
+
* deliberately wider then, so as not to refuse a form the gate accepted
|
|
124
|
+
* (threat T-4-53). The shape is the plan-identifier grammar
|
|
125
|
+
* `agents/pp-planner.md` and `src/phase-id.cts` use: a phase segment (digits,
|
|
126
|
+
* an optional letter, optional decimal parts) followed by one or more numeric
|
|
127
|
+
* segments.
|
|
128
|
+
*
|
|
129
|
+
* What it rejects is what CR-03 actually named, and nothing more: a value that
|
|
130
|
+
* is not a plan identifier at all — a bare plan number (`01`), a task label
|
|
131
|
+
* (`T1`), a slugged identifier (`04-01-vault-trace`), or an empty string. Every
|
|
132
|
+
* one of those, written into a canonically named artifact, produces
|
|
133
|
+
* `plan_id_mismatch`.
|
|
134
|
+
*
|
|
135
|
+
* The agreement test in `tests/vault-command-router.test.cjs` and the gate-side
|
|
136
|
+
* half in `tests/vault-trace.integration.test.cjs` drive both sides over one
|
|
137
|
+
* shared value list, so an independent edit to either cannot pass.
|
|
138
|
+
*/
|
|
139
|
+
const SCAFFOLD_PLAN_ID_RE = vaultStandardsLeaf.SCAFFOLD_PLAN_ID_RE;
|
|
140
|
+
/**
|
|
141
|
+
* The required form, in ONE wording: the `vault standards block` rejection
|
|
142
|
+
* message (04-14 T2). A constant so the refusal and the test that pins it read
|
|
143
|
+
* one sentence (threat T-4-55).
|
|
144
|
+
*
|
|
145
|
+
* ASCII only, and no backticks: the same bytes have to survive a JSON string, a
|
|
146
|
+
* markdown paragraph and a terminal.
|
|
88
147
|
*/
|
|
89
148
|
const SCAFFOLD_PLAN_ID_FORM = "The plan identifier must be the plan's phase-and-number identifier, exactly the " +
|
|
90
149
|
'prefix of its artifact filename (for example 04-02), not a bare plan number, a task ' +
|
|
91
150
|
'label or a slug.';
|
|
92
151
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
152
|
+
* The quick-task half of the form (16-05, D-29), appended to both rejection
|
|
153
|
+
* messages after `SCAFFOLD_PLAN_ID_FORM`.
|
|
154
|
+
*
|
|
155
|
+
* A second constant rather than an edit to the first: the first sentence is
|
|
156
|
+
* already true of a quick task — "exactly the prefix of its artifact filename"
|
|
157
|
+
* — so only the refusal, the one surface a caller reads after being told no,
|
|
158
|
+
* needs to name the quick form.
|
|
96
159
|
*/
|
|
97
|
-
const
|
|
160
|
+
const SCAFFOLD_QUICK_ID_FORM = 'A quick task uses its quick-task id instead, the same filename prefix (for example 261011-8yt).';
|
|
98
161
|
/** Repo-relative artifact paths, long enough for a workstream phase tree. */
|
|
99
162
|
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
163
|
/**
|
|
103
|
-
* Bound and control-character-collapse a caller-supplied path before it
|
|
104
|
-
*
|
|
105
|
-
* on the workflow path, an agent's context
|
|
106
|
-
* an
|
|
164
|
+
* Bound and control-character-collapse a caller-supplied path before it reaches
|
|
165
|
+
* a refusal message (T-03-53). The message reaches an operator's terminal and,
|
|
166
|
+
* on the workflow path, an agent's context — so a hostile path must not be able
|
|
167
|
+
* to forge what looks like an additional line of output, nor run to arbitrary
|
|
168
|
+
* length. Mirrors `sanitizeTraceText` in src/check-command-router.cts; that one
|
|
169
|
+
* also redacts remotes because it quotes ARTIFACT text, while this one only ever
|
|
170
|
+
* quotes the argv value the operator just typed.
|
|
107
171
|
*/
|
|
108
172
|
function boundedPath(value) {
|
|
109
173
|
const collapsed = value
|
|
@@ -117,25 +181,20 @@ function boundedPath(value) {
|
|
|
117
181
|
: collapsed;
|
|
118
182
|
}
|
|
119
183
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
184
|
+
* The gate an override recorded by `vault standards block` suppresses (04-06, SC4).
|
|
185
|
+
*
|
|
186
|
+
* `vault.status` is not a name invented here: it is the `check.query` the
|
|
187
|
+
* blocking vault gate is declared under at both write points
|
|
188
|
+
* (`capabilities/vault/capability.json`), and it is the gate the override
|
|
189
|
+
* line names (`docs/vault-standards-mode.md` § Gates, "The override").
|
|
190
|
+
* Declared once so the emitted record and the documented contract cannot
|
|
191
|
+
* drift apart. Its one owner is the standards leaf
|
|
192
|
+
* (15 D-35), because the standards judgement accepts an override line only
|
|
193
|
+
* under this gate name, and a second literal could drift from the first.
|
|
125
194
|
*/
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
/** The value token after each of `flags`, by index, so a flag's value is never read as a positional. */
|
|
130
|
-
function flagValueIndices(argv, flags) {
|
|
131
|
-
const indices = new Set();
|
|
132
|
-
for (const flag of flags) {
|
|
133
|
-
const i = argv.indexOf(flag);
|
|
134
|
-
if (i !== -1)
|
|
135
|
-
indices.add(i + 1);
|
|
136
|
-
}
|
|
137
|
-
return indices;
|
|
138
|
-
}
|
|
195
|
+
const TRACE_OVERRIDE_GATE = vaultStandardsLeaf.STANDARDS_OVERRIDE_GATE;
|
|
196
|
+
// Default CoreModule implementation. _core seam overrides this for test injection.
|
|
197
|
+
const _defaultCore = { output: io.output };
|
|
139
198
|
/**
|
|
140
199
|
* Render a short plain-text `vault org-profile` report from its structured
|
|
141
200
|
* shape — one line naming the pointer, one naming the resolution, a
|
|
@@ -148,15 +207,26 @@ function flagValueIndices(argv, flags) {
|
|
|
148
207
|
function renderOrgProfileReport(report) {
|
|
149
208
|
const lines = [];
|
|
150
209
|
if (report.pointer.value === null) {
|
|
151
|
-
lines.push(
|
|
210
|
+
lines.push(report.profile.source === 'embedded'
|
|
211
|
+
? 'No org profile package is configured (org.profile is unset) — the embedded org profile applies.'
|
|
212
|
+
: 'No org profile is configured (org.profile is unset) — pp-core\'s built-in defaults apply.');
|
|
213
|
+
}
|
|
214
|
+
else if (report.pointer.value.trim().toLowerCase() === 'none') {
|
|
215
|
+
lines.push('org.profile is "none" — no org profile applies, not even the embedded one; pp-core\'s built-in defaults apply.');
|
|
152
216
|
}
|
|
153
217
|
else {
|
|
154
218
|
lines.push(`org.profile is set to "${report.pointer.value}".`);
|
|
155
219
|
}
|
|
156
|
-
if (report.profile.resolved) {
|
|
220
|
+
if (report.profile.resolved && report.profile.source === 'embedded' && report.pointer.value !== null) {
|
|
221
|
+
lines.push(`The org profile "${report.pointer.value}" could not be resolved — the embedded org profile (pp-core ${report.profile.version ?? 'unknown'}) applies instead.`);
|
|
222
|
+
}
|
|
223
|
+
else if (report.profile.resolved && report.profile.source === 'embedded') {
|
|
224
|
+
lines.push(`Using the embedded org profile shipped with pp-core ${report.profile.version ?? 'unknown'}.`);
|
|
225
|
+
}
|
|
226
|
+
else if (report.profile.resolved) {
|
|
157
227
|
lines.push(`Resolved org profile "${report.profile.name}" version ${report.profile.version ?? 'unknown'} from ${report.profile.source}.`);
|
|
158
228
|
}
|
|
159
|
-
else if (report.pointer.value !== null) {
|
|
229
|
+
else if (report.pointer.value !== null && report.pointer.value.trim().toLowerCase() !== 'none') {
|
|
160
230
|
lines.push(`The org profile "${report.pointer.value}" could not be resolved from the project or the global npm root.`);
|
|
161
231
|
}
|
|
162
232
|
const contributesKeys = Object.keys(report.contributes);
|
|
@@ -166,7 +236,8 @@ function renderOrgProfileReport(report) {
|
|
|
166
236
|
else {
|
|
167
237
|
lines.push('The org profile contributes:');
|
|
168
238
|
for (const key of contributesKeys) {
|
|
169
|
-
|
|
239
|
+
const value = report.contributes[key];
|
|
240
|
+
lines.push(` ${key} = ${value === '' ? '"" (explicitly none)' : value}`);
|
|
170
241
|
}
|
|
171
242
|
}
|
|
172
243
|
if (report.overridden.length > 0) {
|
|
@@ -187,146 +258,74 @@ function renderOrgProfileReport(report) {
|
|
|
187
258
|
return lines.join('\n');
|
|
188
259
|
}
|
|
189
260
|
// ─── Implementation ───────────────────────────────────────────────────────────
|
|
190
|
-
function routeVaultCommand({ args, cwd, raw, error, _vault, _core,
|
|
261
|
+
function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _exec, _orgProfile, _coordinates, _provider, _update, }) {
|
|
191
262
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
192
263
|
const vault = _vault ?? require('./vault.cjs');
|
|
193
264
|
const c = _core ?? _defaultCore;
|
|
194
265
|
const subcommand = args[1];
|
|
266
|
+
// From the REAL leaf, not the `_vault` seam: a governance answer must not be
|
|
267
|
+
// switchable by a test mock.
|
|
268
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
269
|
+
const indexSeam = require('./vault.cjs');
|
|
195
270
|
if (subcommand === 'fetch') {
|
|
196
271
|
c.output(vault.vaultFetch(cwd), raw);
|
|
197
272
|
}
|
|
198
273
|
else if (subcommand === 'status') {
|
|
199
|
-
//
|
|
200
|
-
// from the one
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
274
|
+
// 14-05 (D-11/D-14): the same five flat fields `check vault.status` carries,
|
|
275
|
+
// from the one state function, so the report and the gate cannot tell
|
|
276
|
+
// different stories (UAT F6). From the REAL module (`indexSeam`), not the
|
|
277
|
+
// injectable `vault` seam: a governance answer must not be switchable by a
|
|
278
|
+
// test mock (13-06 precedent). No `block` and no `reason`: this is a report.
|
|
279
|
+
// The state is null with the vault off, so the envelope gains no key.
|
|
280
|
+
const base = vault.vaultStatus(cwd);
|
|
281
|
+
const indexState = indexSeam.computeVaultIndexState(cwd);
|
|
282
|
+
const withIndex = indexState !== null && base['present'] === true
|
|
283
|
+
? { ...base, ...indexSeam.vaultIndexEnvelopeFields(indexState) }
|
|
284
|
+
: base;
|
|
285
|
+
c.output(withIndex, raw);
|
|
204
286
|
}
|
|
205
287
|
else if (subcommand === 'probe') {
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
288
|
+
// STRICT-02: pure reachability for the client vault, the shared fallback and
|
|
289
|
+
// the platform repo — no clone, no cache mutation.
|
|
290
|
+
//
|
|
291
|
+
// T-02-07: reject any unrecognized `--` token BEFORE anything spawns. A typo must never reach a
|
|
292
|
+
// subprocess. The host (pp-core/bin/pp-tools.cjs) already splices out its own
|
|
293
|
+
// global flags before dispatch, which is why the recognized set is one wide.
|
|
294
|
+
const RECOGNIZED_PROBE_FLAGS = new Set(['--raw']);
|
|
295
|
+
const unrecognizedProbeFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_PROBE_FLAGS.has(a));
|
|
296
|
+
if (unrecognizedProbeFlag !== undefined) {
|
|
297
|
+
error(`Unrecognized flag "${unrecognizedProbeFlag}" for vault probe. Supported: --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
298
|
+
return;
|
|
216
299
|
}
|
|
300
|
+
// The probe's ls-remote names its remote by URL, but git still reads the config of the repository the
|
|
301
|
+
// environment selects (measured: `protocol.file.allow=never` in a pointed-to repository's config made the
|
|
302
|
+
// bare runner's ls-remote fail with `transport 'file' not allowed`, while the same call with the location
|
|
303
|
+
// unset answered), so it runs through the owner; transport is kept (D-117).
|
|
304
|
+
c.output(vault.vaultProbe(cwd, { exec: _exec ?? shell_command_projection_cjs_1.execGitLocal }), raw);
|
|
217
305
|
}
|
|
218
306
|
else if (subcommand === 'summary') {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
// NOT implemented as a comma-splitting alias here, unlike
|
|
230
|
-
// `/pp-code-review`'s `--files=a,b` (pp-core/workflows/code-review.md).
|
|
231
|
-
// The auditor marked the alias "optional and secondary"; the required
|
|
232
|
-
// behavior is loud failure, and the error message below names the
|
|
233
|
-
// supported forms instead. The host (pp-core/bin/pp-tools.cjs) already
|
|
234
|
-
// splices out its own global flags (--json-errors/--cwd/--ws/--raw/
|
|
235
|
-
// --pick/--default) before dispatch, which is why the recognized set
|
|
236
|
-
// here is only two flags wide.
|
|
237
|
-
const RECOGNIZED_MATCH_FLAGS = new Set(['--changed', '--raw', '--block']);
|
|
238
|
-
const unrecognizedFlag = args.slice(3).find((a) => a.startsWith('--') && !RECOGNIZED_MATCH_FLAGS.has(a));
|
|
239
|
-
if (unrecognizedFlag !== undefined) {
|
|
240
|
-
error(`Unrecognized flag "${unrecognizedFlag}" for vault practices match. Supported: positional file paths, --changed, --raw, or --block.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
244
|
-
const practices = _practices ?? require('./vault-practices.cjs');
|
|
245
|
-
const runGit = _exec ?? shell_command_projection_cjs_1.execGit;
|
|
246
|
-
let files;
|
|
247
|
-
if (args.includes('--changed')) {
|
|
248
|
-
const gitResult = runGit(['diff', '--name-only', 'HEAD'], { cwd });
|
|
249
|
-
if (gitResult.exitCode !== 0) {
|
|
250
|
-
error('Could not determine changed files: git diff failed (WR-02)', ERROR_REASON.SDK_FAIL_FAST);
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
files = gitResult.stdout.split('\n').map((f) => f.trim()).filter((f) => f.length > 0);
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
files = args.slice(3).filter((a) => !a.startsWith('--'));
|
|
257
|
-
}
|
|
258
|
-
const result = practices.matchPractices({ cwd, files });
|
|
259
|
-
if (args.includes('--block')) {
|
|
260
|
-
// T-25-08: emit the finished <vault_practices> block so the planner
|
|
261
|
-
// embeds it VERBATIM. The planner never renames a field or applies a
|
|
262
|
-
// fence itself — that transformation used to be prose-only, which is
|
|
263
|
-
// what left the phase's prompt-injection mitigation unenforced.
|
|
264
|
-
c.output(result, true, practices.renderPracticesBlock(result));
|
|
265
|
-
}
|
|
266
|
-
else if (raw) {
|
|
267
|
-
c.output(result, true, JSON.stringify(result));
|
|
268
|
-
}
|
|
269
|
-
else {
|
|
270
|
-
c.output(result, true, practices.renderPracticesReport(result));
|
|
271
|
-
}
|
|
307
|
+
// STRICT-05 (02-02): read the STATUS, not the bare string. The write failure
|
|
308
|
+
// used to be swallowed inside vaultSummary, so this arm could only ever print
|
|
309
|
+
// success. It still exits 0 on every path — per D-VH-7 the vault and check
|
|
310
|
+
// commands REPORT and the workflow halts; making this exit non-zero would
|
|
311
|
+
// route the block into the step's `onError: skip` and reopen the fail-open.
|
|
312
|
+
const summary = vault.vaultSummaryResult(cwd);
|
|
313
|
+
if (!summary.ok) {
|
|
314
|
+
// Warning to stderr so the success-path stdout stays byte-identical to
|
|
315
|
+
// before this plan for every healthy project.
|
|
316
|
+
process.stderr.write(`⚠ vault summary: could not write ${summary.path ?? '.planning/vault/SUMMARY.md'} — ${summary.error ?? 'unknown error'}\n`);
|
|
272
317
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// lazy require of the write-back module and before any subprocess can
|
|
276
|
-
// spawn — mirrors the `match` branch's T-24-08b guard exactly. Matched
|
|
277
|
-
// on the WHOLE token so `--name=value` is caught by the same check as
|
|
278
|
-
// `--name value`. The recognized set holds exactly three flags — no
|
|
279
|
-
// flag that disables, bypasses, or relaxes any guard exists.
|
|
280
|
-
const RECOGNIZED_PROMOTE_FLAGS = new Set(['--confirmed', '--expect', '--raw']);
|
|
281
|
-
const promoteArgs = args.slice(3);
|
|
282
|
-
const unrecognizedPromoteFlag = promoteArgs.find((a) => a.startsWith('--') && !RECOGNIZED_PROMOTE_FLAGS.has(a));
|
|
283
|
-
if (unrecognizedPromoteFlag !== undefined) {
|
|
284
|
-
error(`Unrecognized flag "${unrecognizedPromoteFlag}" for vault practices promote. Supported: --confirmed, --expect <token>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
287
|
-
// Positional file path: the first token that is not `--`-prefixed and
|
|
288
|
-
// is not the value immediately following `--expect`.
|
|
289
|
-
const expectFlagIdx = promoteArgs.indexOf('--expect');
|
|
290
|
-
const expectValueIdx = expectFlagIdx !== -1 ? expectFlagIdx + 1 : -1;
|
|
291
|
-
const filePath = promoteArgs.find((a, i) => !a.startsWith('--') && i !== expectValueIdx);
|
|
292
|
-
if (filePath === undefined) {
|
|
293
|
-
error('No practice file was given. Usage: vault practices promote <file> [--confirmed --expect <token>] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const confirmed = promoteArgs.includes('--confirmed');
|
|
297
|
-
const expect = expectValueIdx !== -1 ? promoteArgs[expectValueIdx] : undefined;
|
|
298
|
-
if (confirmed && (expect === undefined || expect.startsWith('--'))) {
|
|
299
|
-
error('This practice was not pushed: the confirmation is missing the preview token it needs. ' +
|
|
300
|
-
'Run the promotion without confirming first, then pass the token it prints.', ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT);
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
304
|
-
const writeBack = _writeBack ?? require('./vault-write-back.cjs');
|
|
305
|
-
const verdict = writeBack.promotePractice({ cwd, filePath, confirmed, expect });
|
|
306
|
-
// Emit the structured verdict BEFORE any error call — a refusal still
|
|
307
|
-
// carries its verdict to stdout. This ordering is the whole point of
|
|
308
|
-
// the uniform D-16 refusal contract and is what lets a wrapper
|
|
309
|
-
// workflow render results without scraping prose.
|
|
310
|
-
if (raw) {
|
|
311
|
-
c.output(verdict, true, JSON.stringify(verdict));
|
|
312
|
-
}
|
|
313
|
-
else {
|
|
314
|
-
c.output(verdict, true, writeBack.renderPromoteReport(verdict));
|
|
315
|
-
}
|
|
316
|
-
if (verdict.status === 'refused') {
|
|
317
|
-
// An action command's refusal must be observable in the exit code —
|
|
318
|
-
// do not adopt the ship gate's always-exit-zero convention here.
|
|
319
|
-
error(verdict.message ?? 'This practice was not promoted.', verdict.reason ?? ERROR_REASON.UNKNOWN);
|
|
320
|
-
}
|
|
318
|
+
if (raw) {
|
|
319
|
+
c.output(summary, true, JSON.stringify(summary));
|
|
321
320
|
}
|
|
322
321
|
else {
|
|
323
|
-
|
|
322
|
+
c.output(summary.index, false);
|
|
324
323
|
}
|
|
325
324
|
}
|
|
326
325
|
else if (subcommand === 'org-profile') {
|
|
327
326
|
// T-33-13: reject any unrecognized `--`-prefixed argument BEFORE any
|
|
328
|
-
// lazy require or resolution work
|
|
329
|
-
//
|
|
327
|
+
// lazy require or resolution work (T-24-08b), so a typo can never trigger
|
|
328
|
+
// filesystem work.
|
|
330
329
|
const RECOGNIZED_ORG_PROFILE_FLAGS = new Set(['--raw']);
|
|
331
330
|
const unrecognizedOrgProfileFlag = args
|
|
332
331
|
.slice(2)
|
|
@@ -353,12 +352,23 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
353
352
|
const platformHost = coordinates.resolvePlatformHost(cwd);
|
|
354
353
|
const effectiveByKey = {
|
|
355
354
|
'vault.provider': provider,
|
|
356
|
-
|
|
355
|
+
// An explicit opt-out (an empty string, from config or profile) reads
|
|
356
|
+
// back as "" rather than null, so it is not reported as overridden.
|
|
357
|
+
[coordinates.SHARED_REPO_CONFIG_KEY]: shared.repo ?? (shared.suppressedKind === 'explicit-opt-out' ? '' : null),
|
|
357
358
|
[coordinates.SHARED_REF_CONFIG_KEY]: shared.ref,
|
|
358
359
|
[coordinates.SHARED_HOST_CONFIG_KEY]: shared.host,
|
|
359
360
|
[coordinates.PLATFORM_HOST_CONFIG_KEY]: platformHost.host,
|
|
360
|
-
//
|
|
361
|
-
//
|
|
361
|
+
// 13-05 / D-20: the two POLICY keys, through their own resolvers.
|
|
362
|
+
// Without these rows both read null, and a profile-seeded value that
|
|
363
|
+
// actually won was reported as `profile offered "true", but "null"
|
|
364
|
+
// won.` (reproduced on 1.15.1) — a diagnostic stating something untrue.
|
|
365
|
+
// The profile carries `vault.required` as the canonical string
|
|
366
|
+
// 'true'/'false', so String(required) compares equal when it won.
|
|
367
|
+
// `resolveVaultRequired` is the SAME ladder the load gate asks
|
|
368
|
+
// (13-REVIEW CR-02): workstream then root config, trimmed, and an
|
|
369
|
+
// unparseable project value falls through to the profile — so the
|
|
370
|
+
// report cannot say the profile lost while the gate enforces it. It is
|
|
371
|
+
// the policy only; with `vault.enabled` off no gate exists to apply it.
|
|
362
372
|
[orgProfile.VAULT_REQUIRED_CONFIG_KEY]: String(orgProfile.resolveVaultRequired(cwd).required),
|
|
363
373
|
};
|
|
364
374
|
// Build `effective`'s key list from ORG_PROFILE_ALLOWLIST rather than a
|
|
@@ -380,21 +390,15 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
380
390
|
overridden.push({ key, profile: profileValue, effective: effectiveValue });
|
|
381
391
|
}
|
|
382
392
|
}
|
|
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]);
|
|
393
|
+
// VAULT-EGRESS-1 (quick 261005-8py): a credential embedded in
|
|
394
|
+
// `vault.fallback_repo` must not reach stdout. `overridden` above compares
|
|
395
|
+
// the RAW values, so redaction happens here, at emission only, through the
|
|
396
|
+
// one owner — never a second redactor.
|
|
397
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
398
|
+
const { redactCloneTarget } = require('./vault.cjs');
|
|
399
|
+
const safe = (key, value) => (key === coordinates.SHARED_REPO_CONFIG_KEY && typeof value === 'string' && value !== ''
|
|
400
|
+
? (redactCloneTarget(value) ?? value) : value);
|
|
401
|
+
const safeMap = (map) => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, safe(k, v)]));
|
|
398
402
|
const report = {
|
|
399
403
|
pointer: { key: orgProfile.ORG_PROFILE_CONFIG_KEY, value: resolution.configured },
|
|
400
404
|
profile: {
|
|
@@ -404,16 +408,12 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
404
408
|
source: resolution.source,
|
|
405
409
|
dataPath: resolution.dataPath,
|
|
406
410
|
},
|
|
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
|
-
})),
|
|
411
|
+
contributes: safeMap(resolution.contributes),
|
|
412
|
+
effective: safeMap(effective),
|
|
413
|
+
overridden: overridden.map((o) => ({ ...o, profile: safe(o.key, o.profile), effective: safe(o.key, o.effective) })),
|
|
414
414
|
ignored: resolution.ignored,
|
|
415
415
|
rejected: resolution.rejected,
|
|
416
|
-
warnings: resolution.warnings
|
|
416
|
+
warnings: resolution.warnings,
|
|
417
417
|
};
|
|
418
418
|
// Never call error() for a project with no profile, and never exit
|
|
419
419
|
// non-zero for a resolution failure — this is a diagnostic, not an
|
|
@@ -426,22 +426,13 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
426
426
|
c.output(report, true, renderOrgProfileReport(report));
|
|
427
427
|
}
|
|
428
428
|
}
|
|
429
|
-
else if (subcommand === 'trace') {
|
|
430
|
-
routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace });
|
|
431
|
-
}
|
|
432
|
-
else if (subcommand === 'audit') {
|
|
433
|
-
routeAudit({ args, cwd, raw, error, vault, c, _exec, _audit });
|
|
434
|
-
}
|
|
435
|
-
else if (subcommand === 'review') {
|
|
436
|
-
routeReview({ args, cwd, raw, c });
|
|
437
|
-
}
|
|
438
429
|
else if (subcommand === 'update') {
|
|
439
|
-
// `vault update [<tag>] [--yes]
|
|
440
|
-
// writes nothing; see src/vault-update.cts for the contract.
|
|
430
|
+
// PBI 74316: `vault update [<tag>] [--yes]`. Without --yes it reports the
|
|
431
|
+
// plan and writes nothing; see src/vault-update.cts for the contract.
|
|
441
432
|
const RECOGNIZED_UPDATE_FLAGS = new Set(['--raw', '--yes']);
|
|
442
433
|
const unrecognizedUpdateFlag = args.slice(2).find((a) => a.startsWith('--') && !RECOGNIZED_UPDATE_FLAGS.has(a));
|
|
443
434
|
if (unrecognizedUpdateFlag !== undefined) {
|
|
444
|
-
error(`Unrecognized flag "${
|
|
435
|
+
error(`Unrecognized flag "${unrecognizedUpdateFlag}" for vault update. Supported: --yes, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
445
436
|
return;
|
|
446
437
|
}
|
|
447
438
|
const updatePositionals = args.slice(2).filter((a) => !a.startsWith('--'));
|
|
@@ -466,109 +457,349 @@ function routeVaultCommand({ args, cwd, raw, error, _vault, _core, _practices, _
|
|
|
466
457
|
c.output(updateResult, true, updateMod.renderVaultUpdate(updateResult));
|
|
467
458
|
}
|
|
468
459
|
}
|
|
460
|
+
else if (subcommand === 'standards') {
|
|
461
|
+
// 15-07 (D-06, D-08, D-13): the standards record verbs. The arm body lives
|
|
462
|
+
// in `routeVaultStandards` below.
|
|
463
|
+
routeVaultStandards({ cwd, args, raw, error, vault, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
|
|
464
|
+
}
|
|
465
|
+
else if (subcommand === 'admission') {
|
|
466
|
+
// 16-04 (D-28, D-05): the arm body lives in `admissionVerb` below.
|
|
467
|
+
admissionVerb({ cwd, rest: args.slice(2), raw, error, c });
|
|
468
|
+
}
|
|
469
|
+
else if (subcommand === 'anchor') {
|
|
470
|
+
// 16-06 (D-31, D-32): the arm body lives in `routeVaultAnchor` below.
|
|
471
|
+
routeVaultAnchor({ cwd, args, raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
|
|
472
|
+
}
|
|
473
|
+
else if (subcommand === 'door-record') {
|
|
474
|
+
// 16-06 (D-12): the arm body lives in `doorRecordVerb` below.
|
|
475
|
+
doorRecordVerb({ cwd, rest: args.slice(2), raw, error, c, exec: _exec ?? shell_command_projection_cjs_1.execGitLocal });
|
|
476
|
+
}
|
|
469
477
|
else {
|
|
470
|
-
error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.
|
|
478
|
+
error(`Unknown vault subcommand. Available: ${VAULT_SUBCOMMANDS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
471
479
|
}
|
|
472
480
|
}
|
|
473
481
|
/**
|
|
474
|
-
* `vault
|
|
475
|
-
*
|
|
476
|
-
*
|
|
477
|
-
*
|
|
478
|
-
*
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
482
|
+
* `vault admission [--explain]` (16-04, D-28, D-05): prints exactly one of
|
|
483
|
+
* `inert | off | on | config-unreadable` and exits 0. With `--explain` and
|
|
484
|
+
* `config-unreadable`, one more line per unreadable file names the file and the
|
|
485
|
+
* repair; `--raw` prints the verdict object instead.
|
|
486
|
+
*
|
|
487
|
+
* The answer comes from `vaultAdmissionVerdict` in the REAL src/vault.cts, never
|
|
488
|
+
* from the injectable `_vault` seam: a governance decision must not be
|
|
489
|
+
* switchable by a test mock.
|
|
490
|
+
* It stays on the cwd's own verdict, not the producer verdict the seven record
|
|
491
|
+
* producers ask (quick 261015-ect, QD-10): this verb is a REPORT of the cwd,
|
|
492
|
+
* which is what every fence asks, so a bindings-less linked worktree answers
|
|
493
|
+
* `inert` here even where its owning checkout is admitted (QD-09 "Not changed").
|
|
494
|
+
*
|
|
495
|
+
* Exit 0 on every answer, because a door fence reads the TOKEN: any stdout other
|
|
496
|
+
* than `on`, `off` or `inert` halts it, and its `|| echo unrecognised` fallback
|
|
497
|
+
* turns a non-zero exit into a halt as well. The only non-zero paths are an
|
|
498
|
+
* argument error here, which therefore halts the fence, never skips it.
|
|
486
499
|
*/
|
|
487
|
-
function
|
|
500
|
+
function admissionVerb({ cwd, rest, raw, error, c }) {
|
|
501
|
+
let explain = false;
|
|
502
|
+
for (const a of rest) {
|
|
503
|
+
if (a === '--raw')
|
|
504
|
+
continue;
|
|
505
|
+
if (a === '--explain') {
|
|
506
|
+
explain = true;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
error(`Unrecognized argument "${boundedPath(a)}" for vault admission. Supported: --explain, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
488
512
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
489
|
-
const
|
|
490
|
-
const
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
513
|
+
const owner = require('./vault.cjs');
|
|
514
|
+
const verdict = owner.vaultAdmissionVerdict(cwd);
|
|
515
|
+
if (raw) {
|
|
516
|
+
c.output(verdict, true, JSON.stringify(verdict));
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const lines = [verdict.state];
|
|
520
|
+
if (explain && verdict.state === 'config-unreadable') {
|
|
521
|
+
for (const u of verdict.unreadable) {
|
|
522
|
+
lines.push(`${u.file}: ${u.error} — repair: fix the JSON in ${u.file} (or remove the key that broke it) and re-run; ` +
|
|
523
|
+
'the gate cannot read this file and refuses to pass having checked nothing');
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
c.output(verdict, true, `${lines.join('\n')}\n`);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* `vault standards block|consulted` (15-07). The CLI owns everything provable
|
|
530
|
+
* about a standards record (D-06): the pin comes from the manifest, a path is
|
|
531
|
+
* accepted only when the loaded INDEX resolves it (D-19), the degraded line is
|
|
532
|
+
* chosen from the measured state (D-08) and the override line comes from the
|
|
533
|
+
* one override builder. The planner fills reason slots and the executor fills
|
|
534
|
+
* status slots; nothing else in the record is typed by an agent.
|
|
535
|
+
*
|
|
536
|
+
* ORDER: unless `vault admission` answers `on` (bindings present, every config
|
|
537
|
+
* readable, the vault switch on) both verbs print nothing and write nothing,
|
|
538
|
+
* before any argument is read — so a project without the vault is
|
|
539
|
+
* byte-identical on every path including a mistyped one, and a standards record
|
|
540
|
+
* cannot be produced on a project whose trace gate is inert (17-REVIEW CR-01,
|
|
541
|
+
* D-13; the same gate `routeVaultAnchor` and `doorRecordVerb` ask). The verdict
|
|
542
|
+
* is asked of the checkout whose vault cache the verbs read (quick 261015-at3,
|
|
543
|
+
* QD-09): in a linked worktree reading its owning checkout's cache — where an
|
|
544
|
+
* executor runs `consulted` and has no untracked bindings of its own — that is
|
|
545
|
+
* the owning checkout, which is also where the gate reading the record runs. Then
|
|
546
|
+
* the verb and the flags are checked.
|
|
547
|
+
*
|
|
548
|
+
* Every governance read is from the REAL module, never the `_vault` seam; only the bounded PLAN read goes through the seam, so
|
|
549
|
+
* a test can count it. Neither verb writes a file.
|
|
550
|
+
*/
|
|
551
|
+
function routeVaultStandards({ cwd, args, raw, error, vault, c, exec }) {
|
|
552
|
+
// `promote` is an operator action, not a record producer: it is dispatched
|
|
553
|
+
// BEFORE the producer admission below, so a vault that is off answers with
|
|
554
|
+
// the promote module's own activation refusal instead of printing nothing.
|
|
555
|
+
if (args[2] === 'promote') {
|
|
556
|
+
standardsPromoteVerb({ cwd, rest: args.slice(3), raw, error, c, exec });
|
|
495
557
|
return;
|
|
496
558
|
}
|
|
497
|
-
//
|
|
498
|
-
|
|
559
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
560
|
+
const owner = require('./vault.cjs');
|
|
561
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
499
562
|
return;
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
563
|
+
const verb = args[2];
|
|
564
|
+
const rest = args.slice(3);
|
|
565
|
+
if (verb === 'block') {
|
|
566
|
+
standardsBlockVerb({ cwd, rest, raw, error, c, owner });
|
|
567
|
+
}
|
|
568
|
+
else if (verb === 'consulted') {
|
|
569
|
+
standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
error(`Unknown standards subcommand. Available: ${STANDARDS_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** The flags `vault standards promote` takes that carry a value. */
|
|
576
|
+
const PROMOTE_VALUE_FLAGS = ['--path', '--entry', '--section', '--expect'];
|
|
577
|
+
/**
|
|
578
|
+
* `vault standards promote <file> [--path <vault-path>] [--entry "<what it is>;
|
|
579
|
+
* used when <…>"] [--section <INDEX section>] [--confirmed --expect <token>]
|
|
580
|
+
* [--raw]` (FORK-ONLY): add a standards document from this project to the
|
|
581
|
+
* client vault with its INDEX.md entry. Without `--confirmed` it only
|
|
582
|
+
* previews; see src/vault-standards-promote.cts for the guards and the two
|
|
583
|
+
* runs. An unrecognised flag is refused BEFORE the module loads or anything
|
|
584
|
+
* spawns (T-29-16); no flag disables or relaxes a guard. The verdict is
|
|
585
|
+
* written to stdout first, then a refusal also exits non-zero: an action
|
|
586
|
+
* command's refusal must be observable in the exit code.
|
|
587
|
+
*/
|
|
588
|
+
function standardsPromoteVerb({ cwd, rest, raw, error, c, exec }) {
|
|
589
|
+
const usage = 'Usage: vault standards promote <file> [--path <vault-path>] [--entry "<what it is>; used when <...>"] '
|
|
590
|
+
+ '[--section <INDEX section>] [--confirmed --expect <token>] [--raw].';
|
|
591
|
+
const values = {};
|
|
592
|
+
let confirmed = false;
|
|
593
|
+
let filePath;
|
|
594
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
595
|
+
const a = rest[i];
|
|
596
|
+
if (a === '--raw')
|
|
597
|
+
continue;
|
|
598
|
+
if (a === '--confirmed') {
|
|
599
|
+
confirmed = true;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (PROMOTE_VALUE_FLAGS.includes(a)) {
|
|
603
|
+
const v = rest[i + 1];
|
|
604
|
+
if (v === undefined || v.startsWith('--')) {
|
|
605
|
+
error(`${a} needs a value. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
values[a] = v;
|
|
609
|
+
i += 1;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (a.startsWith('--')) {
|
|
613
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault standards promote. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
506
614
|
return;
|
|
507
615
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const stage = stageIdx !== -1 ? traceArgs[stageIdx + 1] : undefined;
|
|
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);
|
|
616
|
+
if (filePath !== undefined) {
|
|
617
|
+
error(`vault standards promote takes exactly one document. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
513
618
|
return;
|
|
514
619
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
620
|
+
filePath = a;
|
|
621
|
+
}
|
|
622
|
+
if (filePath === undefined) {
|
|
623
|
+
error(`No standards document was given. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (confirmed && values['--expect'] === undefined) {
|
|
627
|
+
error('This standards document was not pushed: the confirmation is missing the preview token. '
|
|
628
|
+
+ 'Run the promotion without --confirmed first, then pass the token it prints.', ERROR_REASON.VAULT_PROMOTE_CONCURRENT_EDIT);
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
632
|
+
const promote = require('./vault-standards-promote.cjs');
|
|
633
|
+
const verdict = promote.promoteStandard({
|
|
634
|
+
cwd,
|
|
635
|
+
filePath,
|
|
636
|
+
vaultPath: values['--path'] ?? null,
|
|
637
|
+
entry: values['--entry'] ?? null,
|
|
638
|
+
section: values['--section'] ?? null,
|
|
639
|
+
confirmed,
|
|
640
|
+
expect: values['--expect'] ?? null,
|
|
641
|
+
_exec: exec,
|
|
642
|
+
});
|
|
643
|
+
c.output(verdict, true, raw ? JSON.stringify(verdict) : promote.renderPromoteReport(verdict));
|
|
644
|
+
if (verdict.status === 'refused') {
|
|
645
|
+
error(String(verdict.message), String(verdict.reason));
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
function standardsBlockVerb({ cwd, rest, raw, error, c, owner }) {
|
|
649
|
+
// ── arguments ──
|
|
650
|
+
let planId;
|
|
651
|
+
let planGiven = false;
|
|
652
|
+
const positionals = [];
|
|
653
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
654
|
+
const a = rest[i];
|
|
655
|
+
if (a === '--raw')
|
|
656
|
+
continue;
|
|
657
|
+
if (a === '--plan') {
|
|
658
|
+
planGiven = true;
|
|
659
|
+
planId = rest[i + 1];
|
|
660
|
+
i += 1;
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (a.startsWith('--plan=')) {
|
|
664
|
+
planGiven = true;
|
|
665
|
+
planId = a.slice('--plan='.length);
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (a.startsWith('--')) {
|
|
669
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault standards block. ` +
|
|
670
|
+
'Supported: --plan <plan-id>, positional INDEX paths, --raw.', ERROR_REASON.SDK_FAIL_FAST);
|
|
520
671
|
return;
|
|
521
672
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
673
|
+
positionals.push(a);
|
|
674
|
+
}
|
|
675
|
+
if (!planGiven || planId === undefined || planId === '' || planId.startsWith('--')) {
|
|
676
|
+
error('No plan identifier was given. Usage: vault standards block --plan <plan-id> [<index-path>...] [--raw].', ERROR_REASON.SDK_MISSING_ARG);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (!SCAFFOLD_PLAN_ID_RE.test(planId)) {
|
|
680
|
+
error(`Invalid --plan value "${boundedPath(planId)}" for vault standards block. ${SCAFFOLD_PLAN_ID_FORM} ${SCAFFOLD_QUICK_ID_FORM}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
// ── the measured state, read once, from one cache ──
|
|
684
|
+
// The manifest and the INDEX come from the same directory, the one
|
|
685
|
+
// `resolveVaultCacheDir` names (in a main checkout, `.planning/vault/`), so
|
|
686
|
+
// the pin and the resolved paths always describe one cache. "Loaded" and the
|
|
687
|
+
// stamp status are the leaf's predicates, shared with the trace gate.
|
|
688
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
689
|
+
const indexLeaf = require('./vault-index.cjs');
|
|
690
|
+
const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
|
|
691
|
+
const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
|
|
692
|
+
const state = pin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cacheDir });
|
|
693
|
+
const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(pin, state);
|
|
694
|
+
// ── the selection (D-19) ──
|
|
695
|
+
// The repair comes from the one owner the consulted verb and the trace gate
|
|
696
|
+
// also read (15-REVIEW iter-3 WR-01), so the three cannot name different ones.
|
|
697
|
+
const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
|
|
698
|
+
if (indexFix !== null && positionals.length > 0) {
|
|
699
|
+
error(`Vault standards block: no document can be selected: the index is ${indexStatus}. ` +
|
|
700
|
+
'Run the command with no index paths to record that state; to select documents, ' +
|
|
701
|
+
`${indexFix.repair.charAt(0).toLowerCase()}${indexFix.repair.slice(1)} first.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const selectable = vaultStandardsLeaf.standardsResolvedPaths(state);
|
|
705
|
+
const seen = new Set();
|
|
706
|
+
for (const p of positionals) {
|
|
707
|
+
// Exact string membership: no normalisation, no join, no stat (Phase 14
|
|
708
|
+
// D-08). A path the INDEX does not resolve is refused in ONE wording
|
|
709
|
+
// whatever the reason, so the refusal never tells a caller which class of
|
|
710
|
+
// file a path names.
|
|
711
|
+
if (seen.has(p)) {
|
|
712
|
+
error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is listed more than once; name each INDEX path once.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
525
713
|
return;
|
|
526
714
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const runGit = _exec ?? shell_command_projection_cjs_1.execGit;
|
|
533
|
-
const fileInputs = files.map((name) => {
|
|
534
|
-
if (stage !== 'execute')
|
|
535
|
-
return name;
|
|
536
|
-
const res = runGit(['log', '-1', '--format=%H', '--', name], { cwd });
|
|
537
|
-
const digest = res.exitCode === 0 ? res.stdout.trim() : '';
|
|
538
|
-
return GIT_COMMIT_DIGEST_RE.test(digest) ? { name, gitCommit: digest } : name;
|
|
539
|
-
});
|
|
540
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
541
|
-
const traceMod = _trace ?? require('./vault-trace.cjs');
|
|
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));
|
|
715
|
+
seen.add(p);
|
|
716
|
+
if (!selectable.has(p)) {
|
|
717
|
+
error(`Vault standards block: ${indexLeaf.displayVaultPath(p)} is not a selectable INDEX entry. ` +
|
|
718
|
+
'Choose from the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, exactly as written there.', ERROR_REASON.SDK_FAIL_FAST);
|
|
719
|
+
return;
|
|
547
720
|
}
|
|
548
|
-
|
|
549
|
-
|
|
721
|
+
}
|
|
722
|
+
// ── the record ──
|
|
723
|
+
const repoText = pin !== null
|
|
724
|
+
? indexLeaf.sanitiseVaultText(owner.redactCloneTarget(pin.repo) ?? '', 200).replace(/\s+/g, '_')
|
|
725
|
+
: '';
|
|
726
|
+
// Document lines only on a healthy index with a selection; otherwise the
|
|
727
|
+
// one degraded line the measured status implies (D-08).
|
|
728
|
+
const degraded = indexStatus === 'ok' && positionals.length > 0
|
|
729
|
+
? null
|
|
730
|
+
: vaultStandardsLeaf.standardsDegradedLineFor(indexStatus);
|
|
731
|
+
const record = {
|
|
732
|
+
planId,
|
|
733
|
+
repo: pin !== null && repoText !== '' ? repoText : null,
|
|
734
|
+
ref: pin?.ref ?? null,
|
|
735
|
+
sha: pin?.sha ?? null,
|
|
736
|
+
indexStatus,
|
|
737
|
+
documents: degraded === null
|
|
738
|
+
? positionals.map((p) => ({ path: p, ref: pin.ref, sha: pin.sha, reason: '' }))
|
|
739
|
+
: [],
|
|
740
|
+
degraded,
|
|
741
|
+
override: null,
|
|
742
|
+
};
|
|
743
|
+
// ── the override line (D-08): only through the one builder ──
|
|
744
|
+
// Recorded only when the load gate's verdict GENUINELY blocks now and the
|
|
745
|
+
// trace leaf accepts the operator's override reason from this process's
|
|
746
|
+
// environment; a non-blocking verdict records nothing (D-15 of Phase 4). The
|
|
747
|
+
// router names no part of the override stack (the leaf owns it all). The
|
|
748
|
+
// gate name is `TRACE_OVERRIDE_GATE`. The verdict is computed
|
|
749
|
+
// against the SAME cache the stamp above measured (D-36, 15-REVIEW iter-2
|
|
750
|
+
// WR-02), exactly as the trace gate recomputes it, so the suppressed reason
|
|
751
|
+
// recorded here is the one the gate will compare it with, in any checkout.
|
|
752
|
+
const verdict = owner.computeVaultVerdict(cwd, { vaultDir: cacheDir });
|
|
753
|
+
if (verdict.block === true) {
|
|
754
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
755
|
+
const traceLeaf = require('./vault-trace.cjs');
|
|
756
|
+
const ov = traceLeaf.buildStandardsOverrideRecord(cwd, {
|
|
757
|
+
gate: TRACE_OVERRIDE_GATE,
|
|
758
|
+
block: true,
|
|
759
|
+
suppressedReason: verdict.reason,
|
|
760
|
+
});
|
|
761
|
+
if (ov) {
|
|
762
|
+
record.override = { gate: ov.gate, suppressedReason: ov.suppressedReason, who: ov.who, when: ov.when, reason: ov.reason };
|
|
550
763
|
}
|
|
764
|
+
}
|
|
765
|
+
let text;
|
|
766
|
+
try {
|
|
767
|
+
text = vaultStandardsLeaf.serializeStandardsBlock(record);
|
|
768
|
+
}
|
|
769
|
+
catch (e) {
|
|
770
|
+
error(`Vault standards block could not write the record: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
|
|
551
771
|
return;
|
|
552
772
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
773
|
+
const envelope = {
|
|
774
|
+
status: 'ok',
|
|
775
|
+
planId,
|
|
776
|
+
stamp: { repo: record.repo, ref: record.ref, sha: record.sha, indexStatus },
|
|
777
|
+
documents: record.documents.map((d) => ({ path: d.path, ref: d.ref, sha: d.sha })),
|
|
778
|
+
degraded: record.degraded,
|
|
779
|
+
override: record.override,
|
|
780
|
+
text,
|
|
781
|
+
};
|
|
782
|
+
c.output(envelope, true, raw ? JSON.stringify(envelope) : text);
|
|
783
|
+
}
|
|
784
|
+
function standardsConsultedVerb({ cwd, rest, raw, error, c, owner, vault, exec, }) {
|
|
785
|
+
const unrecognized = rest.find((a) => a.startsWith('--') && a !== '--raw');
|
|
786
|
+
if (unrecognized !== undefined) {
|
|
787
|
+
error(`Unrecognized flag "${boundedPath(unrecognized)}" for vault standards consulted. ` +
|
|
788
|
+
'Supported: a positional plan path, --raw.', ERROR_REASON.SDK_FAIL_FAST);
|
|
558
789
|
return;
|
|
559
790
|
}
|
|
560
|
-
const
|
|
561
|
-
if (
|
|
562
|
-
error('
|
|
791
|
+
const positionals = rest.filter((a) => !a.startsWith('--'));
|
|
792
|
+
if (positionals.length !== 1) {
|
|
793
|
+
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
794
|
return;
|
|
564
795
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
//
|
|
568
|
-
//
|
|
569
|
-
//
|
|
796
|
+
const artifactPath = positionals[0];
|
|
797
|
+
const shown = boundedPath(artifactPath);
|
|
798
|
+
// CONTAINMENT FIRST, then the size, then the read (T-03-50): the plan path
|
|
799
|
+
// is an unvalidated positional, and a decision taken on the bounded read's
|
|
800
|
+
// RESULT would be no decision.
|
|
570
801
|
if (artifactPath.indexOf('\u0000') !== -1) {
|
|
571
|
-
error(`The
|
|
802
|
+
error(`The plan path is not a usable path: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
572
803
|
return;
|
|
573
804
|
}
|
|
574
805
|
let projectRoot;
|
|
@@ -579,225 +810,1199 @@ function routeTrace({ args, cwd, raw, error, vault, c, _exec, _trace, }) {
|
|
|
579
810
|
projectRoot = cwd;
|
|
580
811
|
}
|
|
581
812
|
const artifactAbs = node_path_1.default.resolve(cwd, artifactPath);
|
|
582
|
-
if (!
|
|
583
|
-
error(`The
|
|
813
|
+
if (!owner.isInsideDir(projectRoot, artifactAbs)) {
|
|
814
|
+
error(`The plan path resolves outside the project and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
584
815
|
return;
|
|
585
816
|
}
|
|
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));
|
|
817
|
+
let size;
|
|
818
|
+
try {
|
|
819
|
+
const st = node_fs_1.default.statSync(artifactAbs);
|
|
820
|
+
if (!st.isFile())
|
|
821
|
+
throw new Error('not a file');
|
|
822
|
+
size = st.size;
|
|
598
823
|
}
|
|
599
|
-
|
|
600
|
-
|
|
824
|
+
catch {
|
|
825
|
+
error(`The plan path is not a readable file: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (size > STANDARDS_PLAN_READ_BYTES) {
|
|
829
|
+
error(`The plan is ${size} bytes, above the ${STANDARDS_PLAN_READ_BYTES}-byte read cap the trace gate also applies, ` +
|
|
830
|
+
`and was not read: ${shown}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const text = vault.readHeadBounded(artifactAbs, STANDARDS_PLAN_READ_BYTES);
|
|
834
|
+
const parsed = vaultStandardsLeaf.parseStandardsBlock(text, { artifact: shown });
|
|
835
|
+
if (parsed.record === null || parsed.problems.length > 0) {
|
|
836
|
+
const first = parsed.problems[0];
|
|
837
|
+
error(first
|
|
838
|
+
? `Vault standards consulted: ${first.kind}: ${first.detail}`
|
|
839
|
+
: `Vault standards consulted: ${shown} carries no readable ${vaultStandardsLeaf.VAULT_STANDARDS_TAG_OPEN} record. ` +
|
|
840
|
+
'Re-run `pp-tools vault standards block --plan <plan-id> <index-path...>` and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const record = parsed.record;
|
|
844
|
+
// ── absolute cache paths (D-30, C-9) ──
|
|
845
|
+
// Resolved through the one cache resolver, so an executor in a linked
|
|
846
|
+
// worktree (which has no cache of its own) is pointed at the main checkout's.
|
|
847
|
+
// Every path is contained in the cache directory: a block edited by hand
|
|
848
|
+
// could name `../x.md`, and that must be refused before anyone reads it.
|
|
849
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
850
|
+
const indexLeaf = require('./vault-index.cjs');
|
|
851
|
+
const cache = owner.resolveVaultCacheDir(cwd);
|
|
852
|
+
// And every path is a RESOLVED INDEX entry of that cache (D-19, 15-REVIEW
|
|
853
|
+
// WR-04): the executor is told to read each listed document in full, and a
|
|
854
|
+
// withheld or credential file is in the cache too. The same reads and the
|
|
855
|
+
// same set the block verb selects from, so a PLAN that never passed
|
|
856
|
+
// plan:post, or was edited after it, cannot hand such a path to an executor.
|
|
857
|
+
// One reason-free wording, as the block verb's: the refusal never says why.
|
|
858
|
+
const loadedPin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cache.dir));
|
|
859
|
+
const indexState = loadedPin === null ? null : owner.computeVaultIndexState(cwd, { vaultDir: cache.dir });
|
|
860
|
+
const selectable = vaultStandardsLeaf.standardsResolvedPaths(indexState);
|
|
861
|
+
// 15-REVIEW iter-2 WR-05: with no vault loaded, or a loaded vault whose INDEX
|
|
862
|
+
// is degraded, NO document can be listed, and the per-path wording below
|
|
863
|
+
// would name a loaded vault that is not there and send the reader back to
|
|
864
|
+
// `vault standards block`, where the only record left to write is a degraded
|
|
865
|
+
// one: a governed plan would lose its document record. So this case is
|
|
866
|
+
// refused first, naming the measured cause and the repair (`vault fetch`),
|
|
867
|
+
// from the one owner the block verb and the trace gate also read (iter-3
|
|
868
|
+
// WR-01), so the gate cannot send the same record somewhere else.
|
|
869
|
+
const indexStatus = vaultStandardsLeaf.standardsMeasuredIndexStatus(loadedPin, indexState);
|
|
870
|
+
const indexFix = vaultStandardsLeaf.standardsIndexRepair(indexStatus);
|
|
871
|
+
if (record.documents.length > 0 && indexFix !== null) {
|
|
872
|
+
error(`Vault standards consulted: ${indexFix.cause}, so no document of ${shown} can be listed. ` +
|
|
873
|
+
`${indexFix.repair}, then re-run this command.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
874
|
+
return;
|
|
601
875
|
}
|
|
876
|
+
const documents = [];
|
|
877
|
+
for (const d of record.documents) {
|
|
878
|
+
const cachePath = node_path_1.default.join(cache.dir, d.path);
|
|
879
|
+
if (node_path_1.default.isAbsolute(d.path) || !owner.isInsideDir(cache.dir, cachePath) || cachePath === cache.dir) {
|
|
880
|
+
error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} resolves outside the vault cache ` +
|
|
881
|
+
'and was not listed. Re-run `pp-tools vault standards block` for this plan and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
if (!selectable.has(d.path)) {
|
|
885
|
+
error(`Vault standards consulted: ${indexLeaf.displayVaultPath(d.path)} in ${shown} is not a selectable INDEX entry ` +
|
|
886
|
+
'of the loaded vault and was not listed. Re-run `pp-tools vault standards block` for this plan, choosing from ' +
|
|
887
|
+
'the entries the ## INDEX section of .planning/vault/SUMMARY.md lists, and embed its output verbatim.', ERROR_REASON.SDK_FAIL_FAST);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
documents.push({ path: d.path, ref: d.ref, sha: d.sha, cachePath });
|
|
891
|
+
}
|
|
892
|
+
// 16-06 (D-14/D-31/D-35): the anchor line, the section's last line. END is
|
|
893
|
+
// HEAD now (the executor runs this after the last task commit); START is the
|
|
894
|
+
// anchor `vault anchor start` recorded in the plan's own directory, when one
|
|
895
|
+
// is there. Both come from git and the anchors file, never from the agent.
|
|
896
|
+
// HEAD unreadable (no commit, git failing) writes no line at all, and the
|
|
897
|
+
// strict points then type the missing anchor (16-07); a write point is
|
|
898
|
+
// silent on it (D-16).
|
|
899
|
+
const headRun = exec(['rev-parse', 'HEAD'], { cwd });
|
|
900
|
+
const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
|
|
901
|
+
const start = vaultSeal.readDirAnchors(node_path_1.default.dirname(artifactAbs))?.start ?? null;
|
|
902
|
+
const anchors = vaultSeal.ANCHOR_SHA_RE.test(end) ? { start, end } : null;
|
|
903
|
+
const section = vaultStandardsLeaf.serializeConsultedSection(record, { anchors });
|
|
904
|
+
const envelope = {
|
|
905
|
+
status: 'ok',
|
|
906
|
+
planId: record.planId,
|
|
907
|
+
stamp: { repo: record.repo, ref: record.ref, sha: record.sha },
|
|
908
|
+
documents,
|
|
909
|
+
// The section and this field echo one sanitised line (15-REVIEW WR-03).
|
|
910
|
+
degraded: record.degraded === null ? null : vaultStandardsLeaf.standardsEchoDegraded(record.degraded),
|
|
911
|
+
cacheSource: cache.source,
|
|
912
|
+
section,
|
|
913
|
+
};
|
|
914
|
+
c.output(envelope, true, raw ? JSON.stringify(envelope) : section);
|
|
602
915
|
}
|
|
603
916
|
/**
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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.
|
|
917
|
+
* The real vault module, for the governance reads of the 16-06 verbs: the
|
|
918
|
+
* admission verdict, containment and phase resolution must not be switchable
|
|
919
|
+
* by a test mock.
|
|
617
920
|
*/
|
|
618
|
-
function
|
|
921
|
+
function anchorOwner() {
|
|
619
922
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
923
|
+
return require('./vault.cjs');
|
|
924
|
+
}
|
|
925
|
+
/** The project root the router's other containment checks use: `findProjectRoot(cwd) || cwd`. */
|
|
926
|
+
function projectRootOf(cwd) {
|
|
927
|
+
try {
|
|
928
|
+
return findProjectRoot(cwd) || cwd;
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return cwd;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* THE CONTAINMENT LADDER for a path a 16-06 verb is handed (the
|
|
936
|
+
* `standardsConsultedVerb` order, T-03-50): a NUL byte is refused, the project
|
|
937
|
+
* root is resolved, the path must resolve inside the root AND inside its
|
|
938
|
+
* `.planning/` tree (never the tree itself), and only then is it stat'ed.
|
|
939
|
+
*
|
|
940
|
+
* - `dir`: an existing directory;
|
|
941
|
+
* - `file`: an existing regular file (not a symlink);
|
|
942
|
+
* - `artifact`: a file that may not exist yet, whose directory exists inside
|
|
943
|
+
* `.planning/`; when it does exist it must be a regular file (not a
|
|
944
|
+
* symlink), so a write can never be redirected out of the tree.
|
|
945
|
+
*
|
|
946
|
+
* `.planning/` is required, not only the root, because every path these verbs
|
|
947
|
+
* write or read is a planning record; an anchors file or a door record in the
|
|
948
|
+
* project's source tree would be committed as source (D-55). The refusal names
|
|
949
|
+
* the bounded argv value, never file content.
|
|
950
|
+
*/
|
|
951
|
+
function resolvePlanningPath(cwd, raw, want) {
|
|
952
|
+
const shown = boundedPath(raw);
|
|
953
|
+
if (raw.indexOf('\u0000') !== -1)
|
|
954
|
+
return { ok: false, reason: 'path-unusable', message: `"${shown}" is not a usable path` };
|
|
955
|
+
const owner = anchorOwner();
|
|
956
|
+
const root = projectRootOf(cwd);
|
|
957
|
+
const abs = node_path_1.default.resolve(cwd, raw);
|
|
958
|
+
if (!owner.isInsideDir(root, abs)) {
|
|
959
|
+
return { ok: false, reason: 'outside-project', message: `"${shown}" resolves outside the project and was not used` };
|
|
960
|
+
}
|
|
961
|
+
const planning = node_path_1.default.join(root, '.planning');
|
|
962
|
+
const under = want === 'artifact' ? node_path_1.default.dirname(abs) : abs;
|
|
963
|
+
if (!owner.isInsideDir(planning, under) || (want !== 'artifact' && owner.isInsideDir(abs, planning))) {
|
|
964
|
+
return { ok: false, reason: 'outside-planning', message: `"${shown}" is not inside the project's .planning/ tree` };
|
|
965
|
+
}
|
|
966
|
+
const rel = node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/');
|
|
967
|
+
try {
|
|
968
|
+
if (want === 'dir') {
|
|
969
|
+
if (!node_fs_1.default.statSync(abs).isDirectory())
|
|
970
|
+
return { ok: false, reason: 'not-a-directory', message: `"${shown}" is not a directory` };
|
|
971
|
+
}
|
|
972
|
+
else if (want === 'file') {
|
|
973
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
974
|
+
if (!st.isFile())
|
|
975
|
+
return { ok: false, reason: 'not-a-file', message: `"${shown}" is not a regular file` };
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
if (!node_fs_1.default.statSync(node_path_1.default.dirname(abs)).isDirectory()) {
|
|
979
|
+
return { ok: false, reason: 'not-a-directory', message: `the directory of "${shown}" does not exist` };
|
|
980
|
+
}
|
|
981
|
+
if (node_fs_1.default.existsSync(abs) || isDanglingLink(abs)) {
|
|
982
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
983
|
+
if (!st.isFile())
|
|
984
|
+
return { ok: false, reason: 'not-a-file', message: `"${shown}" exists and is not a regular file` };
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
catch {
|
|
989
|
+
return { ok: false, reason: 'not-found', message: `"${shown}" does not exist` };
|
|
990
|
+
}
|
|
991
|
+
return { ok: true, root, abs, rel };
|
|
992
|
+
}
|
|
993
|
+
/** A symlink whose target is missing: `existsSync` says false, and a write would follow it. */
|
|
994
|
+
function isDanglingLink(abs) {
|
|
995
|
+
try {
|
|
996
|
+
return node_fs_1.default.lstatSync(abs).isSymbolicLink();
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
return false;
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* `vault anchor <verb>` (16-06). INERT FIRST (D-13): unless the producer
|
|
1004
|
+
* admission verdict is `on` (bindings present, every config readable, the
|
|
1005
|
+
* vault gate active) the verb prints nothing and writes nothing, before any
|
|
1006
|
+
* argument is read, so a project without the vault is byte-identical on every
|
|
1007
|
+
* path, including a mistyped one. A `config-unreadable` project is halted by
|
|
1008
|
+
* the admission fence that precedes every call site, never here.
|
|
1009
|
+
*
|
|
1010
|
+
* The verdict is the PRODUCER's (quick 261015-ect, QD-10), the one the other
|
|
1011
|
+
* six record producers ask: equal to the cwd's own except in a linked worktree
|
|
1012
|
+
* with no bindings of its own, where it is the owning checkout's. So all seven
|
|
1013
|
+
* answer alike in every shape; the door fences, which probe the cwd's bindings
|
|
1014
|
+
* first, never reach this verb in such a worktree, so only by-hand calls move.
|
|
1015
|
+
*/
|
|
1016
|
+
function routeVaultAnchor({ cwd, args, raw, error, c, exec }) {
|
|
1017
|
+
if (anchorOwner().vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
629
1018
|
return;
|
|
1019
|
+
const verb = args[2];
|
|
1020
|
+
const rest = args.slice(3);
|
|
1021
|
+
if (verb === 'start') {
|
|
1022
|
+
anchorStartVerb({ cwd, rest, raw, error, c, exec });
|
|
630
1023
|
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1024
|
+
else if (verb === 'backfill') {
|
|
1025
|
+
anchorBackfillVerb({ cwd, rest, raw, error, c, exec });
|
|
1026
|
+
}
|
|
1027
|
+
else if (verb === 'certify') {
|
|
1028
|
+
anchorCertifyVerb({ cwd, rest, raw, error, c, exec });
|
|
1029
|
+
}
|
|
1030
|
+
else {
|
|
1031
|
+
error(`Unknown vault anchor verb. Available: ${ANCHOR_VERBS.join(', ')}`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* `vault anchor start <dir>` (16-06, D-31): record HEAD as the START anchor of
|
|
1036
|
+
* a phase or quick directory, in `<dir>/.vault-anchors.json`
|
|
1037
|
+
* (`{ start, recordedAt }`, written by the seal leaf's `writeDirStartAnchor`).
|
|
1038
|
+
*
|
|
1039
|
+
* Re-running at the SAME HEAD keeps the recorded start (`kept: true`, nothing
|
|
1040
|
+
* written). At any other HEAD it records HEAD as the new start and says which
|
|
1041
|
+
* start it superseded (`replaced`) — 16 review iteration 2 CR-02, D-98, which
|
|
1042
|
+
* reverses D-93's "keep the first start". Keeping it let every record written
|
|
1043
|
+
* after a resume carry the first start, so its range covered a commit made
|
|
1044
|
+
* between the two runs: a claim of governance nothing granted (rule 3). The
|
|
1045
|
+
* superseded start is kept in the directory's history, newest first, and an
|
|
1046
|
+
* END-only record (a linked worktree's, D-59) takes the latest start that
|
|
1047
|
+
* precedes its own end, so a record written before the resume still covers
|
|
1048
|
+
* its own work. Commits made between the last record and the resume are
|
|
1049
|
+
* covered by nothing and the strict gate names them; the phase's own work
|
|
1050
|
+
* among them is repaired with the attributed start back-fill
|
|
1051
|
+
* (`vault anchor backfill <summary> --from <commit>`), never by a silent
|
|
1052
|
+
* widening.
|
|
1053
|
+
*
|
|
1054
|
+
* Exit 0 on every refusal, which travels as `{ ok: false, reason, message }`
|
|
1055
|
+
* (the step prints it and continues; the strict points type a missing
|
|
1056
|
+
* start). An argument error is the only exit 1.
|
|
1057
|
+
*/
|
|
1058
|
+
function anchorStartVerb({ cwd, rest, raw, error, c, exec }) {
|
|
1059
|
+
const positionals = [];
|
|
1060
|
+
for (const a of rest) {
|
|
1061
|
+
if (a === '--raw')
|
|
1062
|
+
continue;
|
|
1063
|
+
if (a.startsWith('--')) {
|
|
1064
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault anchor start. Supported: a positional directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
643
1065
|
return;
|
|
644
1066
|
}
|
|
1067
|
+
positionals.push(a);
|
|
1068
|
+
}
|
|
1069
|
+
if (positionals.length !== 1) {
|
|
1070
|
+
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);
|
|
1071
|
+
return;
|
|
645
1072
|
}
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
1073
|
+
const emit = (result, text) => {
|
|
1074
|
+
c.output(result, true, raw ? JSON.stringify(result) : text);
|
|
1075
|
+
};
|
|
1076
|
+
const located = resolvePlanningPath(cwd, positionals[0], 'dir');
|
|
1077
|
+
if (!located.ok) {
|
|
1078
|
+
emit({ ok: false, action: 'start', reason: located.reason, message: located.message }, `No start anchor was recorded: ${located.message}.`);
|
|
649
1079
|
return;
|
|
650
1080
|
}
|
|
651
|
-
|
|
1081
|
+
const head = exec(['rev-parse', 'HEAD'], { cwd });
|
|
1082
|
+
const sha = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
1083
|
+
if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
|
|
1084
|
+
const message = 'HEAD could not be read as a full commit id (no commit yet, or git failed)';
|
|
1085
|
+
emit({ ok: false, action: 'start', reason: 'head-unreadable', message }, `No start anchor was recorded: ${message}.`);
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const file = `${located.rel}/${vaultSeal.VAULT_ANCHORS_FILE}`;
|
|
1089
|
+
const existing = vaultSeal.readDirAnchors(located.abs);
|
|
1090
|
+
if (existing !== null && existing.start === sha) {
|
|
1091
|
+
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).`);
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
let recorded;
|
|
652
1095
|
try {
|
|
653
|
-
|
|
1096
|
+
recorded = vaultSeal.writeDirStartAnchor(located.abs, sha);
|
|
654
1097
|
}
|
|
655
1098
|
catch {
|
|
656
|
-
|
|
1099
|
+
const message = `${file} could not be written`;
|
|
1100
|
+
emit({ ok: false, action: 'start', reason: 'write-failed', message }, `No start anchor was recorded: ${message}.`);
|
|
1101
|
+
return;
|
|
657
1102
|
}
|
|
658
|
-
const
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1103
|
+
const replaced = existing !== null ? { replaced: existing.start } : {};
|
|
1104
|
+
emit({ ok: true, action: 'start', start: recorded.start, recordedAt: recorded.recordedAt, file, ...replaced }, existing !== null
|
|
1105
|
+
? `Recorded start anchor ${recorded.start} in ${file}, replacing ${existing.start} as the start of every record written from now on; `
|
|
1106
|
+
+ 'the earlier start stays in the directory\'s history for a record that ended before this one.'
|
|
1107
|
+
: `Recorded start anchor ${recorded.start} in ${file}.`);
|
|
1108
|
+
}
|
|
1109
|
+
// ─── 16-19: the certificate writer (D-107, D-108) ────────────────────────────
|
|
1110
|
+
/** How long the strict check `vault anchor certify` runs may take before it is a `check-failed` refusal. */
|
|
1111
|
+
const CERTIFY_CHECK_TIMEOUT_MS = 120_000;
|
|
1112
|
+
/** The most stdout the certify verb reads from its child check (a larger envelope is `check-failed`). */
|
|
1113
|
+
const CERTIFY_CHECK_MAX_BUFFER = 8 * 1024 * 1024;
|
|
1114
|
+
/** At most this many problem kinds, added ends or removed ends travel in one certify refusal. */
|
|
1115
|
+
const CERTIFY_LIST_CAP = 20;
|
|
1116
|
+
/** How much of the child check's stderr a `check-failed` refusal quotes. */
|
|
1117
|
+
const CERTIFY_STDERR_HEAD = 120;
|
|
1118
|
+
/**
|
|
1119
|
+
* `vault anchor certify <phase-dir>` (16 gap closure 16-19, D-107, D-108): the
|
|
1120
|
+
* ONE writer of a phase directory's `.vault-certificates.json`. A certificate
|
|
1121
|
+
* says "a clean strict check of this phase judged this SUMMARY's END, at this
|
|
1122
|
+
* HEAD, at this time"; only a certified phase record bounds a later phase's
|
|
1123
|
+
* SEAL-01 reach (16-18), so the certificate is the recorded fact D-101 said the
|
|
1124
|
+
* gate did not have.
|
|
1125
|
+
*
|
|
1126
|
+
* WHY A CHILD PROCESS. `check vault.trace` is read-only by contract (TRACE-04)
|
|
1127
|
+
* and its handler emits to stdout. Running it as the enforcer does, as
|
|
1128
|
+
* `pp-tools check vault.trace <dir> --unpaired strict --raw`, keeps its exit-0
|
|
1129
|
+
* posture and its envelope exactly what every gate reads, and this verb only
|
|
1130
|
+
* reads that envelope. The engine is the one running (`pp-tools.cjs` beside
|
|
1131
|
+
* this module's directory); the positional is the contained rel, after the
|
|
1132
|
+
* subcommand, never in an option position.
|
|
1133
|
+
*
|
|
1134
|
+
* WHY THE ENDS AND HEAD ARE COLLECTED BEFORE THE SPAWN, AND RE-CHECKED AFTER.
|
|
1135
|
+
* A certificate for an end the check never judged, or stamped with a HEAD the
|
|
1136
|
+
* check did not judge at, is a certificate for a check that never passed: the
|
|
1137
|
+
* shape D-106 closed at the leaf, reopened by its writer. So HEAD is read and
|
|
1138
|
+
* the phase's ended SUMMARY records are collected first, only those ends are
|
|
1139
|
+
* certified, and after the child returns a different HEAD is `head-moved` and a
|
|
1140
|
+
* different set of ends is `records-changed`, each writing nothing. The verb
|
|
1141
|
+
* refuses rather than certifying the intersection, so a concurrent writer is
|
|
1142
|
+
* visible and never silently narrowed; a SUMMARY added while the check ran is
|
|
1143
|
+
* certified by the next clean run.
|
|
1144
|
+
*
|
|
1145
|
+
* WHY THE GIT ENVIRONMENT IS SCRUBBED, FOR THE VERB'S READS AND THE CHILD
|
|
1146
|
+
* ALIKE. A git hook or an agent shell exports the repository pointer
|
|
1147
|
+
* (`GIT_DIR`, `GIT_WORK_TREE`, …), so an inherited value is ordinary, and a
|
|
1148
|
+
* lasting certificate must never describe another repository's HEAD or
|
|
1149
|
+
* verdict. The overlay comes from its one owner, `scrubbedGitEnv` in
|
|
1150
|
+
* `src/vault.cts` (the cache resolver's precedent, 260915-mzx R2): every
|
|
1151
|
+
* inherited `GIT_*` key is `undefined`, which unsets it, because neither
|
|
1152
|
+
* `execGit` nor `spawnSync` passes an undefined value on.
|
|
1153
|
+
*
|
|
1154
|
+
* WHY THE OVERRIDE VARIABLE IS EMPTIED (D-108). An override never certifies:
|
|
1155
|
+
* `checked_with_override`, an envelope carrying `override` and one carrying
|
|
1156
|
+
* `overrideRejected` are each refused, and the child's `VAULT_OVERRIDE_ENV` is
|
|
1157
|
+
* the empty string (which the trace leaf reads as absent), so an operator's
|
|
1158
|
+
* per-run override in the calling shell can never turn a blocking check into a
|
|
1159
|
+
* certificate, whatever a later engine's check does with that variable.
|
|
1160
|
+
*
|
|
1161
|
+
* WHY A QUICK DIRECTORY IS REFUSED (D-104). A quick task's record never bounds
|
|
1162
|
+
* a later phase, so it is never certified (D-113 keeps the leaf from reading
|
|
1163
|
+
* one as certified even if a file named its end).
|
|
1164
|
+
*
|
|
1165
|
+
* A skipped call certifies nothing, which only widens the next phase's reach
|
|
1166
|
+
* and never hides a commit (D-110). Exit 0 on every refusal, which travels as
|
|
1167
|
+
* `{ ok: false, action: 'certify', reason, … }`; an argument error is the only
|
|
1168
|
+
* exit 1. Inert before any argument is read (`routeVaultAnchor`, D-13).
|
|
1169
|
+
*/
|
|
1170
|
+
function anchorCertifyVerb({ cwd, rest, raw, error, c, exec }) {
|
|
1171
|
+
const positionals = [];
|
|
1172
|
+
for (const a of rest) {
|
|
1173
|
+
if (a === '--raw')
|
|
1174
|
+
continue;
|
|
1175
|
+
if (a.startsWith('--')) {
|
|
1176
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault anchor certify. Supported: a positional phase directory under .planning/, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
positionals.push(a);
|
|
1180
|
+
}
|
|
1181
|
+
if (positionals.length !== 1) {
|
|
1182
|
+
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);
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const emit = (result, text) => {
|
|
1186
|
+
c.output(result, true, raw ? JSON.stringify(result) : text);
|
|
1187
|
+
};
|
|
1188
|
+
const refuse = (reason, message, extra = {}) => {
|
|
1189
|
+
emit({ ok: false, action: 'certify', reason, ...extra, message }, `Nothing was certified: ${message}.`);
|
|
1190
|
+
};
|
|
1191
|
+
const located = resolvePlanningPath(cwd, positionals[0], 'dir');
|
|
1192
|
+
if (!located.ok) {
|
|
1193
|
+
refuse(located.reason, located.message);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (!vaultSeal.isPhaseRecordRel(`${located.rel}/x`)) {
|
|
1197
|
+
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`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const gitEnv = anchorOwner().scrubbedGitEnv(process.env);
|
|
1201
|
+
const readHead = () => {
|
|
1202
|
+
const r = exec(['rev-parse', 'HEAD'], { cwd, env: gitEnv });
|
|
1203
|
+
const sha = r.exitCode === 0 ? r.stdout.trim() : '';
|
|
1204
|
+
return vaultSeal.ANCHOR_SHA_RE.test(sha) ? sha : '';
|
|
1205
|
+
};
|
|
1206
|
+
const head = readHead();
|
|
1207
|
+
if (head === '') {
|
|
1208
|
+
refuse('head-unreadable', 'HEAD could not be read as a full commit id (no commit yet, or git failed)');
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
// A filesystem walk, no git: the ends of the SUMMARY records directly in this
|
|
1212
|
+
// directory, sorted and unique.
|
|
1213
|
+
const collectEnds = () => {
|
|
1214
|
+
const scan = vaultSeal.collectCoveringRecords(located.root);
|
|
1215
|
+
const prefix = `${located.rel}/`;
|
|
1216
|
+
const ends = scan.records
|
|
1217
|
+
.filter((r) => vaultSeal.isSummaryRecordRel(r.rel) && r.rel.startsWith(prefix) && r.rel.indexOf('/', prefix.length) === -1)
|
|
1218
|
+
.map((r) => r.end);
|
|
1219
|
+
return [...new Set(ends)].sort();
|
|
1220
|
+
};
|
|
1221
|
+
const endsBefore = collectEnds();
|
|
1222
|
+
if (endsBefore.length === 0) {
|
|
1223
|
+
const message = `${located.rel} has no SUMMARY with an end anchor, so there is nothing to certify; the strict check was not run`;
|
|
1224
|
+
emit({ ok: false, action: 'certify', reason: 'no-ended-record', message }, `Nothing was certified: ${message}.`);
|
|
668
1225
|
return;
|
|
669
1226
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
1227
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1228
|
+
const { VAULT_OVERRIDE_ENV } = require('./vault-trace.cjs');
|
|
1229
|
+
const child = childProcess.spawnSync(process.execPath, [node_path_1.default.join(__dirname, '..', 'pp-tools.cjs'), 'check', 'vault.trace', located.rel, '--unpaired', 'strict', '--raw'], {
|
|
1230
|
+
cwd: located.root,
|
|
1231
|
+
encoding: 'utf8',
|
|
1232
|
+
timeout: CERTIFY_CHECK_TIMEOUT_MS,
|
|
1233
|
+
maxBuffer: CERTIFY_CHECK_MAX_BUFFER,
|
|
1234
|
+
env: { ...process.env, ...gitEnv, [VAULT_OVERRIDE_ENV]: '' },
|
|
1235
|
+
windowsHide: true,
|
|
675
1236
|
});
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1237
|
+
const stderrHead = boundedPath(String(child.stderr ?? '').slice(0, CERTIFY_STDERR_HEAD));
|
|
1238
|
+
const failed = (why) => {
|
|
1239
|
+
refuse('check-failed', `the strict check of ${located.rel} could not be read (${why})`, { detail: `${why}; stderr: ${stderrHead}` });
|
|
1240
|
+
};
|
|
1241
|
+
if (child.error) {
|
|
1242
|
+
failed(`the check did not run: ${boundedPath(child.error.message).slice(0, CERTIFY_STDERR_HEAD)}`);
|
|
1243
|
+
return;
|
|
680
1244
|
}
|
|
681
|
-
|
|
682
|
-
|
|
1245
|
+
if (child.signal) {
|
|
1246
|
+
failed(`the check was ended by ${child.signal}`);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (child.status !== 0) {
|
|
1250
|
+
failed(`the check exited ${child.status}`);
|
|
1251
|
+
return;
|
|
683
1252
|
}
|
|
1253
|
+
const stdout = String(child.stdout ?? '');
|
|
1254
|
+
if (stdout.trim() === '') {
|
|
1255
|
+
failed('the check printed nothing');
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
let envelope;
|
|
1259
|
+
try {
|
|
1260
|
+
const parsed = JSON.parse(stdout);
|
|
1261
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1262
|
+
failed('the check printed JSON that is not an object');
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
envelope = parsed;
|
|
1266
|
+
}
|
|
1267
|
+
catch {
|
|
1268
|
+
failed('the check printed something that is not JSON');
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
// The verdict gate (D-107, D-108): a clean strict verdict, and nothing else.
|
|
1272
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(envelope, k);
|
|
1273
|
+
const status = typeof envelope.status === 'string' ? boundedPath(envelope.status) : null;
|
|
1274
|
+
const block = typeof envelope.block === 'boolean' ? envelope.block : null;
|
|
1275
|
+
const clean = envelope.status === 'checked' && envelope.block === false && !has('override') && !has('overrideRejected');
|
|
1276
|
+
if (!clean) {
|
|
1277
|
+
const problems = Array.isArray(envelope.problems) ? envelope.problems : [];
|
|
1278
|
+
const kinds = [...new Set(problems
|
|
1279
|
+
.map((p) => (p && typeof p === 'object' ? p.kind : undefined))
|
|
1280
|
+
.filter((k) => typeof k === 'string')
|
|
1281
|
+
.map((k) => boundedPath(k)))].sort().slice(0, CERTIFY_LIST_CAP);
|
|
1282
|
+
const overridden = envelope.status === 'checked_with_override' || has('override');
|
|
1283
|
+
refuse(overridden ? 'overridden' : 'not-clean', `the strict check of ${located.rel} answered ${status ?? 'no status'}, block ${String(block)}`
|
|
1284
|
+
+ `${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}`
|
|
1285
|
+
+ `${overridden ? '; an override never certifies (D-108)' : ''}`, { status, block, kinds });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
// After the child returned clean: the HEAD it judged at, then the ends it judged.
|
|
1289
|
+
const headAfter = readHead();
|
|
1290
|
+
if (headAfter !== head) {
|
|
1291
|
+
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 });
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
const endsAfter = collectEnds();
|
|
1295
|
+
const beforeSet = new Set(endsBefore);
|
|
1296
|
+
const afterSet = new Set(endsAfter);
|
|
1297
|
+
const added = endsAfter.filter((e) => !beforeSet.has(e));
|
|
1298
|
+
const removed = endsBefore.filter((e) => !afterSet.has(e));
|
|
1299
|
+
if (added.length > 0 || removed.length > 0) {
|
|
1300
|
+
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) });
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
// Only the ends collected BEFORE the spawn, stamped with the HEAD read before it.
|
|
1304
|
+
const file = `${located.rel}/${vaultSeal.VAULT_CERTIFICATES_FILE}`;
|
|
1305
|
+
const already = new Set(vaultSeal.readDirCertificates(located.abs).map((cert) => cert.end));
|
|
1306
|
+
const fresh = endsBefore.filter((e) => !already.has(e));
|
|
1307
|
+
const old = endsBefore.filter((e) => already.has(e));
|
|
1308
|
+
if (fresh.length > 0) {
|
|
1309
|
+
const at = new Date().toISOString();
|
|
1310
|
+
try {
|
|
1311
|
+
vaultSeal.appendDirCertificates(located.abs, fresh.map((end) => ({ end, head, at })));
|
|
1312
|
+
}
|
|
1313
|
+
catch (e) {
|
|
1314
|
+
refuse('write-failed', `${file} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
emit({ ok: true, action: 'certify', dir: located.rel, file, head, certified: fresh, alreadyCertified: old, ...(fresh.length === 0 ? { kept: true } : {}) }, fresh.length === 0
|
|
1319
|
+
? `Kept: every end anchor of ${located.rel} is already certified (${old.length}); nothing written.`
|
|
1320
|
+
: `Certified ${fresh.length} end anchor(s) of ${located.rel} at HEAD ${head} after its strict check passed clean (${old.length} already certified).`);
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* The text's lines, split on the same boundaries the record parsers split on
|
|
1324
|
+
* (`\r\n`, `\r`, `\n`), with their offsets, so an insertion lands at a line the
|
|
1325
|
+
* parser counted and every other byte stays where it was.
|
|
1326
|
+
*/
|
|
1327
|
+
function lineSpans(text) {
|
|
1328
|
+
const spans = [];
|
|
1329
|
+
const re = /\r\n|\r|\n/g;
|
|
1330
|
+
let last = 0;
|
|
1331
|
+
let m;
|
|
1332
|
+
while ((m = re.exec(text)) !== null) {
|
|
1333
|
+
spans.push({ start: last, end: m.index, eol: m[0] });
|
|
1334
|
+
last = m.index + m[0].length;
|
|
1335
|
+
}
|
|
1336
|
+
spans.push({ start: last, end: text.length, eol: '' });
|
|
1337
|
+
return spans;
|
|
684
1338
|
}
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
return `${
|
|
1339
|
+
/** Insert `lines` as new lines directly after line `after`, in that line's own line ending. */
|
|
1340
|
+
function insertLinesAfter(text, spans, after, lines) {
|
|
1341
|
+
const eol = spans[after].eol || (spans.find((sp) => sp.eol !== '')?.eol ?? '\n');
|
|
1342
|
+
const at = spans[after].end;
|
|
1343
|
+
return `${text.slice(0, at)}${lines.map((l) => `${eol}${l}`).join('')}${text.slice(at)}`;
|
|
690
1344
|
}
|
|
691
1345
|
/**
|
|
692
|
-
* `vault
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
1346
|
+
* `vault anchor backfill <summary> --at <sha>` (16-06, D-32):
|
|
1347
|
+
* give a record written before anchors existed its anchors, ATTRIBUTED. There
|
|
1348
|
+
* is no silent restamp at HEAD: `--at` is required (the operator states which
|
|
1349
|
+
* commit the record's work ended at), it must be an ancestor of HEAD, and the
|
|
1350
|
+
* record gains, beside the anchor, who ran the back-fill (`git config
|
|
1351
|
+
* user.name`, bounded) and when. 16-07 reports a back-filled anchor as a note
|
|
1352
|
+
* on every later check. `--at HEAD` is allowed, and is still attributed.
|
|
1353
|
+
*
|
|
1354
|
+
* The record is a SUMMARY's `## Standards Consulted` section: the anchor line
|
|
1355
|
+
* with its `backfilled by <who> at <iso>` tail is inserted as the section's
|
|
1356
|
+
* last line (no start: the record takes its directory's, D-59).
|
|
1357
|
+
*
|
|
1358
|
+
* Every other byte of the file is kept: the new text is the old one with lines
|
|
1359
|
+
* inserted, and it is parsed back before it is written — the anchors must read
|
|
1360
|
+
* back as written and the rest of the record must parse to exactly what it
|
|
1361
|
+
* parsed to before, or nothing is written. The write is a temporary sibling
|
|
1362
|
+
* renamed into place.
|
|
1363
|
+
*
|
|
1364
|
+
* Refused, writing nothing: a record that already carries an anchor (a valid,
|
|
1365
|
+
* a dropped or a malformed one: `already-anchored`), a file with no
|
|
1366
|
+
* Standards Consulted section (`no-record`), an `--at` that is not an ancestor of HEAD. A
|
|
1367
|
+
* path outside `.planning/`, a malformed sha and `--from`/`--attest` beside
|
|
1368
|
+
* `--at` are argument errors (exit 1), refused before any read or git call;
|
|
1369
|
+
* the state refusals are `ok: false` envelopes (exit 0).
|
|
1370
|
+
*
|
|
1371
|
+
* 16 review iteration 3 CR-01 (D-102): `--at --from <commit>` set a start of
|
|
1372
|
+
* the operator's choosing (the root commit, say), and D-98's `--from` alone
|
|
1373
|
+
* widened an anchored record's start the same way; either covered every commit
|
|
1374
|
+
* in between with one attribution and named none. Both are gone. The one repair
|
|
1375
|
+
* left for a commit a record's range misses is `--from <recorded start>
|
|
1376
|
+
* --attest <commit>` (`attestVerb`), which covers that commit alone.
|
|
709
1377
|
*/
|
|
710
|
-
function
|
|
711
|
-
const
|
|
712
|
-
|
|
1378
|
+
function anchorBackfillVerb({ cwd, rest, raw, error, c, exec }) {
|
|
1379
|
+
const usage = 'Usage: vault anchor backfill <summary-path> --at <commit> [--raw], or, on a record that carries an end, '
|
|
1380
|
+
+ '--from <a start its directory recorded> --attest <commit> [--attest <commit> ...] [--raw].';
|
|
1381
|
+
const positionals = [];
|
|
1382
|
+
const shas = {};
|
|
1383
|
+
const attest = [];
|
|
1384
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1385
|
+
const a = rest[i];
|
|
1386
|
+
if (a === '--raw')
|
|
1387
|
+
continue;
|
|
1388
|
+
if (a === '--at' || a === '--from' || a === '--attest') {
|
|
1389
|
+
const v = rest[i + 1];
|
|
1390
|
+
if (v === undefined || v.startsWith('--')) {
|
|
1391
|
+
error(`${a} needs a commit. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (a === '--attest')
|
|
1395
|
+
attest.push(v);
|
|
1396
|
+
else
|
|
1397
|
+
shas[a] = v;
|
|
1398
|
+
i += 1;
|
|
1399
|
+
continue;
|
|
1400
|
+
}
|
|
1401
|
+
if (a.startsWith('--')) {
|
|
1402
|
+
error(`Unrecognized flag "${boundedPath(a)}" for vault anchor backfill. Supported: --at <commit>, --from <commit>, --attest <commit>, --raw.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1403
|
+
return;
|
|
1404
|
+
}
|
|
1405
|
+
positionals.push(a);
|
|
1406
|
+
}
|
|
1407
|
+
if (positionals.length !== 1) {
|
|
1408
|
+
error(`vault anchor backfill takes exactly one SUMMARY path. ${usage}`, positionals.length === 0 ? ERROR_REASON.SDK_MISSING_ARG : ERROR_REASON.SDK_FAIL_FAST);
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (shas['--at'] === undefined && shas['--from'] === undefined) {
|
|
1412
|
+
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);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
// 16 review iteration 3 CR-01 (D-102): nothing widens a record's start any
|
|
1416
|
+
// more. `--at` gives a record with no anchor its END (its start is its
|
|
1417
|
+
// directory's, D-59); `--from` only bounds `--attest`, which names each
|
|
1418
|
+
// commit that counts as covered, one by one.
|
|
1419
|
+
if (shas['--at'] !== undefined && (shas['--from'] !== undefined || attest.length > 0)) {
|
|
1420
|
+
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);
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
if (shas['--at'] === undefined && (shas['--from'] === undefined || attest.length === 0)) {
|
|
1424
|
+
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);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
for (const [flag, value] of [['--at', shas['--at']], ['--from', shas['--from']], ...attest.map((v) => ['--attest', v])]) {
|
|
1428
|
+
if (value !== undefined && !vaultSeal.ANCHOR_SHA_RE.test(value)) {
|
|
1429
|
+
error(`${flag} is not a full lowercase commit id (40 or 64 hex). ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
const located = resolvePlanningPath(cwd, positionals[0], 'file');
|
|
1434
|
+
if (!located.ok) {
|
|
1435
|
+
error(`The SUMMARY path ${located.message}; a back-fill rewrites only a record under .planning/.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
const at = shas['--at'];
|
|
1439
|
+
const from = shas['--from'];
|
|
1440
|
+
const emit = (result, text) => {
|
|
1441
|
+
c.output(result, true, raw ? JSON.stringify(result) : text);
|
|
713
1442
|
};
|
|
714
|
-
const
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1443
|
+
const refuse = (reason, message) => {
|
|
1444
|
+
emit({ ok: false, action: 'backfill', file: located.rel, reason, message }, `No anchor was back-filled: ${message}.`);
|
|
1445
|
+
};
|
|
1446
|
+
const size = node_fs_1.default.statSync(located.abs).size;
|
|
1447
|
+
if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
|
|
1448
|
+
refuse('too-large', `${located.rel} is ${size} bytes, above the ${vaultSeal.TRACE_ARTIFACT_BYTE_CAP}-byte read cap the record readers apply`);
|
|
719
1449
|
return;
|
|
720
1450
|
}
|
|
721
|
-
const
|
|
722
|
-
const tokens = args.slice(3).filter((a) => a !== '--raw' && !(force && a === '--force'));
|
|
1451
|
+
const text = node_fs_1.default.readFileSync(located.abs, 'utf8');
|
|
723
1452
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
724
|
-
const
|
|
725
|
-
const
|
|
726
|
-
if (!
|
|
727
|
-
|
|
1453
|
+
const traceLeaf = require('./vault-trace.cjs');
|
|
1454
|
+
const section = vaultStandardsLeaf.parseConsultedSection(text);
|
|
1455
|
+
if (!section.present || section.planId === null) {
|
|
1456
|
+
refuse('no-record', `${located.rel} carries no ${vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING} section to back-fill`);
|
|
728
1457
|
return;
|
|
729
1458
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
const partition = (payload.partition ?? {});
|
|
737
|
-
const coverage = typeof payload.mechanicalCoverageLine === 'string' ? payload.mechanicalCoverageLine : null;
|
|
738
|
-
if (verb === 'scaffold') {
|
|
739
|
-
if (node_fs_1.default.existsSync(abs) && !force) {
|
|
740
|
-
const statement = `Vault review: ${file} already exists, so it was left as it is. Re-run with --force to replace it ` +
|
|
741
|
-
'with a fresh scaffold, or run vault review finalize to check it.';
|
|
742
|
-
say({ status: 'exists', file, statement }, statement);
|
|
1459
|
+
const anchored = section.anchors !== undefined
|
|
1460
|
+
|| section.problems.some((p) => p.kind === vaultStandardsLeaf.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED);
|
|
1461
|
+
if (at === undefined) {
|
|
1462
|
+
// `--from` + `--attest`: the attestation (16 review iteration 3 CR-01, D-102).
|
|
1463
|
+
if (!anchored) {
|
|
1464
|
+
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)`);
|
|
743
1465
|
return;
|
|
744
1466
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
phaseToken: target.phaseToken,
|
|
1467
|
+
attestVerb({
|
|
1468
|
+
cwd, exec, text, located, from, attest, refuse, emit, traceLeaf,
|
|
748
1469
|
});
|
|
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
1470
|
return;
|
|
757
1471
|
}
|
|
758
|
-
|
|
759
|
-
|
|
1472
|
+
if (anchored) {
|
|
1473
|
+
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)`);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
// The shas reach git only now, shape-checked, after the subcommand (T-16-21).
|
|
1477
|
+
if (exec(['merge-base', '--is-ancestor', at, 'HEAD'], { cwd }).exitCode !== 0) {
|
|
1478
|
+
refuse('at-not-ancestor', `--at ${at} is not an ancestor of HEAD (or is not in this repository)`);
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
const backfill = { by: traceLeaf.resolveBackfillIdentity(cwd), at: new Date().toISOString() };
|
|
1482
|
+
const spans = lineSpans(text);
|
|
1483
|
+
const live = vaultStandardsLeaf.findLiveSection(text, vaultStandardsLeaf.STANDARDS_CONSULTED_HEADING);
|
|
1484
|
+
const lastBody = live.body[live.body.length - 1];
|
|
1485
|
+
const line = vaultStandardsLeaf.formatStandardsAnchorLine({ start: from ?? null, end: at, backfill });
|
|
1486
|
+
const next = insertLinesAfter(text, spans, lastBody, [line]);
|
|
1487
|
+
const back = vaultStandardsLeaf.parseConsultedSection(next);
|
|
1488
|
+
const before = section;
|
|
1489
|
+
const expected = { ...(from !== undefined ? { start: from } : {}), end: at, backfill };
|
|
1490
|
+
const strip = (p) => JSON.stringify({ ...p, anchors: undefined, headingLine: undefined });
|
|
1491
|
+
const agrees = back.present
|
|
1492
|
+
&& JSON.stringify(back.anchors) === JSON.stringify({ end: expected.end, ...(from !== undefined ? { start: from } : {}), backfill })
|
|
1493
|
+
&& strip(back) === strip(before);
|
|
1494
|
+
if (!agrees) {
|
|
1495
|
+
refuse('rewrite-unverifiable', `the back-filled section in ${located.rel} would not read back as written, so nothing was written`);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
const tmp = `${located.abs}.${process.pid}.tmp`;
|
|
760
1499
|
try {
|
|
761
|
-
|
|
1500
|
+
node_fs_1.default.writeFileSync(tmp, next, 'utf8');
|
|
1501
|
+
node_fs_1.default.renameSync(tmp, located.abs);
|
|
762
1502
|
}
|
|
763
1503
|
catch {
|
|
764
|
-
|
|
765
|
-
|
|
1504
|
+
try {
|
|
1505
|
+
node_fs_1.default.unlinkSync(tmp);
|
|
1506
|
+
}
|
|
1507
|
+
catch { /* nothing to clean */ }
|
|
1508
|
+
refuse('write-failed', `${located.rel} could not be written`);
|
|
766
1509
|
return;
|
|
767
1510
|
}
|
|
768
|
-
const
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
countsLine: result.expectedCountsLine,
|
|
778
|
-
problems,
|
|
1511
|
+
const result = {
|
|
1512
|
+
ok: true,
|
|
1513
|
+
action: 'backfill',
|
|
1514
|
+
file: located.rel,
|
|
1515
|
+
family: 'standards',
|
|
1516
|
+
endCommit: at,
|
|
1517
|
+
...(from !== undefined ? { startCommit: from } : {}),
|
|
1518
|
+
backfilledBy: backfill.by,
|
|
1519
|
+
backfilledAt: backfill.at,
|
|
779
1520
|
};
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1521
|
+
emit(result, `Back-filled ${located.rel}: end ${at}${from !== undefined ? `, start ${from}` : ''}, by ${backfill.by} at ${backfill.at}.`);
|
|
1522
|
+
}
|
|
1523
|
+
/**
|
|
1524
|
+
* The attestation (16 review iteration 3 CR-01, D-102; replaces D-98's start
|
|
1525
|
+
* back-fill): `vault anchor backfill <summary> --from <start> --attest <commit>
|
|
1526
|
+
* [--attest <commit> ...]` on a record that already carries an end.
|
|
1527
|
+
*
|
|
1528
|
+
* D-98 records a new directory start on every resume, so the phase's own
|
|
1529
|
+
* commits made between its last record and the resume are covered by nothing,
|
|
1530
|
+
* and the strict gate names them. Whether such a commit is the phase's work or
|
|
1531
|
+
* an ungoverned one is not something the engine can tell. D-98's repair widened
|
|
1532
|
+
* the record's START, which covered every commit in between — an ungoverned one
|
|
1533
|
+
* made by someone else between the runs included — and left a note naming none
|
|
1534
|
+
* of them. An attestation widens nothing: each `--attest` names ONE commit, and
|
|
1535
|
+
* that commit alone counts as covered by the record. Every later strict check
|
|
1536
|
+
* lists it, with its subject and paths and who attested it. A commit nobody
|
|
1537
|
+
* attested stays named, and stays a block.
|
|
1538
|
+
*
|
|
1539
|
+
* Refused, writing nothing (each an `ok: false` envelope):
|
|
1540
|
+
* - a record with no valid end (`at-required`, `no-end`) or a malformed anchor;
|
|
1541
|
+
* - `--from` that is not a start the record's own directory recorded
|
|
1542
|
+
* (`vault anchor start` writes them, D-98 keeps the superseded ones), so no
|
|
1543
|
+
* attestation reaches below where the CLI saw this phase begin
|
|
1544
|
+
* (`from-not-recorded`), or that is not an ancestor of the record's end;
|
|
1545
|
+
* - an `--attest` that is not on the record's own (first-parent) line between
|
|
1546
|
+
* `--from` and its end, a merge commit, a commit touching no source path, one
|
|
1547
|
+
* the record already covers, one already attested on it, or given twice.
|
|
1548
|
+
*
|
|
1549
|
+
* The attestations are written to `<dir>/.vault-attestations.json` (the seal
|
|
1550
|
+
* leaf's `appendDirAttestations`), never into the record: the record's bytes,
|
|
1551
|
+
* and the range they state, do not change. `by` is the git `user.name` of
|
|
1552
|
+
* whoever ran the command — what the CLI can read, not a verified identity —
|
|
1553
|
+
* and the envelope says so (`byVerified: false`).
|
|
1554
|
+
*/
|
|
1555
|
+
function attestVerb({ cwd, exec, text, located, from, attest, refuse, emit, traceLeaf }) {
|
|
1556
|
+
const dirAbs = node_path_1.default.dirname(located.abs);
|
|
1557
|
+
const recordName = node_path_1.default.basename(located.abs);
|
|
1558
|
+
const kind = vaultSeal.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED;
|
|
1559
|
+
const isAncestor = (a, d) => vaultSeal.isAnchorSha(a) && vaultSeal.isAnchorSha(d)
|
|
1560
|
+
&& exec(['merge-base', '--is-ancestor', a, d], { cwd }).exitCode === 0;
|
|
1561
|
+
const anchors = vaultSeal.resolveRecordAnchors(text, kind, dirAbs, { isAncestor });
|
|
1562
|
+
if (anchors.problems.some((p) => p.kind === 'anchor_invalid')) {
|
|
1563
|
+
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`);
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1566
|
+
const end = anchors.end;
|
|
1567
|
+
if (end === undefined) {
|
|
1568
|
+
refuse('no-end', `${located.rel} carries no valid end anchor; give it one with --at <commit> first`);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const dir = vaultSeal.readDirAnchors(dirAbs);
|
|
1572
|
+
const recorded = dir === null ? [] : [dir.start, ...(dir.earlier ?? []).map((e) => e.start)];
|
|
1573
|
+
if (!recorded.includes(from)) {
|
|
1574
|
+
refuse('from-not-recorded', `--from ${from} is not a start this record's directory recorded (${recorded.length} recorded, by vault anchor start); `
|
|
1575
|
+
+ 'an attestation reaches back only to a start the CLI recorded for this phase, never to an arbitrary commit');
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
if (from === end || !isAncestor(from, end)) {
|
|
1579
|
+
refuse('from-not-ancestor', `--from ${from} is not an ancestor of the record's end ${end}`);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const cap = vaultSeal.SEAL_CANDIDATE_CAP;
|
|
1583
|
+
const lineRun = exec(['rev-list', '--first-parent', '--parents', `--max-count=${cap + 1}`, `^${from}`, end], { cwd });
|
|
1584
|
+
const rows = lineRun.exitCode === 0 ? lineRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).map((l) => l.split(/\s+/)) : null;
|
|
1585
|
+
if (rows === null || !rows.every((r) => r.every((x) => vaultSeal.isAnchorSha(x)))) {
|
|
1586
|
+
refuse('git-failed', 'the commits between --from and the record\'s end could not be listed');
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
if (rows.length > cap) {
|
|
1590
|
+
refuse('too-many', `more than ${cap} commits lie between --from and the record's end`);
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1593
|
+
const onLine = new Map(rows.map((r) => [r[0], r.length - 1]));
|
|
1594
|
+
let own = new Set();
|
|
1595
|
+
if (anchors.start !== undefined && isAncestor(anchors.start, end)) {
|
|
1596
|
+
const ownRun = exec(['rev-list', '--first-parent', `--max-count=${cap + 1}`, `^${anchors.start}`, end], { cwd });
|
|
1597
|
+
if (ownRun.exitCode !== 0) {
|
|
1598
|
+
refuse('git-failed', 'the commits the record already covers could not be listed');
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
own = new Set(ownRun.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean));
|
|
1602
|
+
}
|
|
1603
|
+
const already = new Set(vaultSeal.attestedFor(vaultSeal.readDirAttestations(dirAbs), recordName).map((a) => a.sha));
|
|
1604
|
+
const by = traceLeaf.resolveBackfillIdentity(cwd);
|
|
1605
|
+
const at = new Date().toISOString();
|
|
1606
|
+
const entries = [];
|
|
1607
|
+
for (const sha of attest) {
|
|
1608
|
+
if (entries.some((e) => e.sha === sha)) {
|
|
1609
|
+
refuse('attest-duplicate', `--attest ${sha} was given twice`);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1612
|
+
if (!onLine.has(sha)) {
|
|
1613
|
+
refuse('attest-not-in-range', `--attest ${sha} is not on ${located.rel}'s own line between --from ${from} and its end ${end}`);
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
if (onLine.get(sha) > 1) {
|
|
1617
|
+
refuse('attest-merge', `--attest ${sha} is a merge commit; attest the commits it brought in, each by its own id`);
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (own.has(sha)) {
|
|
1621
|
+
refuse('attest-already-covered', `--attest ${sha} is already inside ${located.rel}'s own range; it needs no attestation`);
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (already.has(sha)) {
|
|
1625
|
+
refuse('attest-already-attested', `--attest ${sha} is already attested on ${located.rel}`);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const tree = exec(['diff-tree', '-r', '-z', '--root', '--no-commit-id', '--name-only', '--no-renames', sha], { cwd });
|
|
1629
|
+
const subj = exec(['log', '-1', '--format=%s', sha], { cwd });
|
|
1630
|
+
if (tree.exitCode !== 0 || subj.exitCode !== 0) {
|
|
1631
|
+
refuse('git-failed', `the paths or subject of ${sha} could not be read`);
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const paths = tree.stdout.split('\0').filter((p) => p !== '' && !vaultSeal.isNonSourcePath(p));
|
|
1635
|
+
if (paths.length === 0) {
|
|
1636
|
+
refuse('attest-not-source', `--attest ${sha} touches no governed source path, so no check names it and there is nothing to attest`);
|
|
1637
|
+
return;
|
|
1638
|
+
}
|
|
1639
|
+
const flat = (v, n) => {
|
|
1640
|
+
const f = v.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1641
|
+
return f.length > n ? `${f.slice(0, n)}…` : f;
|
|
1642
|
+
};
|
|
1643
|
+
entries.push({
|
|
1644
|
+
record: recordName,
|
|
1645
|
+
from,
|
|
1646
|
+
sha,
|
|
1647
|
+
subject: flat(subj.stdout, vaultSeal.SEAL_SUBJECT_CAP),
|
|
1648
|
+
paths: paths.slice(0, vaultSeal.ATTESTATION_PATHS_CAP).map((p) => flat(p, 1000)),
|
|
1649
|
+
by,
|
|
1650
|
+
at,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
try {
|
|
1654
|
+
vaultSeal.appendDirAttestations(dirAbs, entries);
|
|
1655
|
+
}
|
|
1656
|
+
catch (e) {
|
|
1657
|
+
refuse('write-failed', `${vaultSeal.VAULT_ATTESTATIONS_FILE} next to ${located.rel} could not be written (${boundedPath(e instanceof Error ? e.message : String(e))})`);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
const listed = entries.map((e) => ({ sha: e.sha, subject: e.subject, paths: e.paths }));
|
|
1661
|
+
emit({
|
|
1662
|
+
ok: true,
|
|
1663
|
+
action: 'attest',
|
|
1664
|
+
file: located.rel,
|
|
1665
|
+
family: 'standards',
|
|
1666
|
+
from,
|
|
1667
|
+
endCommit: end,
|
|
1668
|
+
attested: listed,
|
|
1669
|
+
attestedBy: by,
|
|
1670
|
+
byVerified: false,
|
|
1671
|
+
attestedAt: at,
|
|
1672
|
+
}, `Attested ${entries.length} commit${entries.length === 1 ? '' : 's'} on ${located.rel}, by ${by} (the git user.name, not verified) at ${at}: `
|
|
1673
|
+
+ `${entries.map((e) => `${e.sha.slice(0, 12)} "${e.subject}" (${e.paths.join(', ')})`).join('; ')}. Every later strict check lists each one.`);
|
|
1674
|
+
}
|
|
1675
|
+
/** The longest free-text value a door record line carries (the reason-clause bound, with room). */
|
|
1676
|
+
const DOOR_TEXT_MAX = 190;
|
|
1677
|
+
/**
|
|
1678
|
+
* A free-text value made safe for a door record line: the vault leaf's display
|
|
1679
|
+
* sanitiser (controls, bidi, zero-width), backticks turned into
|
|
1680
|
+
* apostrophes, bounded. Empty or the literal `none` becomes `unknown`, because
|
|
1681
|
+
* `none` is how a door record spells an absent value.
|
|
1682
|
+
*/
|
|
1683
|
+
function doorText(value, opts = {}) {
|
|
1684
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1685
|
+
const indexLeaf = require('./vault-index.cjs');
|
|
1686
|
+
let t = indexLeaf.sanitiseVaultText(typeof value === 'string' ? value : '', DOOR_TEXT_MAX).replace(/`/g, "'").trim();
|
|
1687
|
+
if (opts.token === true)
|
|
1688
|
+
t = t.replace(/\s+/g, '_');
|
|
1689
|
+
return t === '' || t === 'none' ? 'unknown' : t;
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* The vault coordinates a door worked under, or the degraded one-liner saying
|
|
1693
|
+
* why there are none (D-12 "or") — read by the standards block verb's own pin
|
|
1694
|
+
* reader, `standardsLoadedPin` over the manifest of the one resolved cache; no
|
|
1695
|
+
* loaded pin records the degraded line `vault standards block` would write.
|
|
1696
|
+
*
|
|
1697
|
+
* The repo has already been through `redactCloneTarget` (`readManifest`), so
|
|
1698
|
+
* no credential reaches the record.
|
|
1699
|
+
*/
|
|
1700
|
+
function doorVaultPin(cwd, owner) {
|
|
1701
|
+
const cacheDir = owner.resolveVaultCacheDir(cwd).dir;
|
|
1702
|
+
const pin = vaultStandardsLeaf.standardsLoadedPin(owner.readManifest(cacheDir));
|
|
1703
|
+
if (pin !== null && vaultSeal.ANCHOR_SHA_RE.test(pin.sha)) {
|
|
1704
|
+
return { vault: { repo: doorText(pin.repo, { token: true }), ref: doorText(pin.ref, { token: true }), sha: pin.sha }, degraded: null };
|
|
1705
|
+
}
|
|
1706
|
+
const line = vaultStandardsLeaf.standardsDegradedLineFor('not-loaded');
|
|
1707
|
+
return { vault: null, degraded: doorText(`${line.kind}: ${line.reason}`) };
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Where a door artifact may live: a phase directory (root or workstream), a
|
|
1711
|
+
* quick-task directory, or `.planning/audits`. Those are exactly the
|
|
1712
|
+
* directories the seal readers walk (`hasRecordedVaultArtifacts`, 16-07's
|
|
1713
|
+
* covering-record walk), so a record written anywhere else would be a record
|
|
1714
|
+
* no reader ever finds: the door would believe it was recorded and nothing
|
|
1715
|
+
* would see it (D-55).
|
|
1716
|
+
*/
|
|
1717
|
+
function isDoorArtifactLocation(rel) {
|
|
1718
|
+
const parts = rel.split('/');
|
|
1719
|
+
if (parts[0] !== '.planning')
|
|
1720
|
+
return false;
|
|
1721
|
+
if (parts.length === 4 && (parts[1] === 'phases' || parts[1] === 'quick'))
|
|
1722
|
+
return true;
|
|
1723
|
+
if (parts.length === 6 && parts[1] === 'workstreams' && parts[3] === 'phases')
|
|
1724
|
+
return true;
|
|
1725
|
+
return parts.length === 3 && parts[1] === 'audits';
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* `vault door-record --begin --door <name>` (16 review CR-01): record HEAD as
|
|
1729
|
+
* the door's START in `.planning/.vault-door-starts.json` (the seal leaf's
|
|
1730
|
+
* `writeDoorStart`), before the door's agent runs. The record step then reads
|
|
1731
|
+
* it, so the start survives the agent's fresh shell per step and is never
|
|
1732
|
+
* typed. Called after admission (`on`) and the door-name check; takes no other
|
|
1733
|
+
* flag. Every refusal is an error (exit 1), like the record itself: a door that
|
|
1734
|
+
* cannot capture its start halts before its agent writes anything.
|
|
1735
|
+
*/
|
|
1736
|
+
function doorBegin({ cwd, door, extra, raw, error, c, exec, usage }) {
|
|
1737
|
+
if (extra.length > 0) {
|
|
1738
|
+
error(`--begin takes only --door; ${extra.join(', ')} belong to the record step. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
const root = projectRootOf(cwd);
|
|
1742
|
+
const planning = node_path_1.default.join(root, '.planning');
|
|
1743
|
+
try {
|
|
1744
|
+
if (!node_fs_1.default.statSync(planning).isDirectory())
|
|
1745
|
+
throw new Error('not a directory');
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
error('The project has no .planning/ directory, so the door start has nowhere to be recorded.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
const head = exec(['rev-parse', 'HEAD'], { cwd });
|
|
1752
|
+
const sha = head.exitCode === 0 ? head.stdout.trim() : '';
|
|
1753
|
+
if (!vaultSeal.ANCHOR_SHA_RE.test(sha)) {
|
|
1754
|
+
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);
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
let recorded;
|
|
1758
|
+
try {
|
|
1759
|
+
recorded = vaultSeal.writeDoorStart(planning, door, sha);
|
|
1760
|
+
}
|
|
1761
|
+
catch {
|
|
1762
|
+
error(`.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE} could not be written, so no door start was recorded.`, ERROR_REASON.UNKNOWN);
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
const file = `.planning/${vaultSeal.VAULT_DOOR_STARTS_FILE}`;
|
|
1766
|
+
const result = { ok: true, action: 'begin', door, start: recorded.start, recordedAt: recorded.recordedAt, file };
|
|
1767
|
+
c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door's start ${recorded.start.slice(0, 12)} in ${file}.`);
|
|
1768
|
+
}
|
|
1769
|
+
/**
|
|
1770
|
+
* `vault door-record --door <name> --artifact <path> [--phase <N|dir>]`
|
|
1771
|
+
* (16-06, D-12), after `vault door-record --begin --door <name>` (16 review
|
|
1772
|
+
* CR-01): the ONE verb that writes a door's record, a mode-stamped
|
|
1773
|
+
* `## Vault Door Record` section (`formatDoorRecord`, the seal leaf's grammar)
|
|
1774
|
+
* appended to the door's own artifact.
|
|
1775
|
+
*
|
|
1776
|
+
* The record carries the mode, the vault ref and sha (or the degraded
|
|
1777
|
+
* one-liner), the governing phase, the START commit the door captured before
|
|
1778
|
+
* its agent ran, the END commit (HEAD now) and when it was written. The start
|
|
1779
|
+
* is the one the door's `--begin` recorded in `.planning/`, and nothing else
|
|
1780
|
+
* (16 review iteration 3 CR-02, D-103: an explicit `--start` was a side door
|
|
1781
|
+
* that widened a record to any ancestor of HEAD with no note); nothing in the
|
|
1782
|
+
* record is typed by an agent (the
|
|
1783
|
+
* Phase 15 D-06 lesson), and no shell variable has to survive between the
|
|
1784
|
+
* door's steps.
|
|
1785
|
+
*
|
|
1786
|
+
* ORDER: admission first and silent (D-13: no output, no file, unless the
|
|
1787
|
+
* producer admission verdict answers `on` — the one every vault record producer
|
|
1788
|
+
* asks, quick 261015-ect QD-10: the cwd's own verdict except in a linked
|
|
1789
|
+
* worktree with no bindings of its own, where it is the owning checkout's; the
|
|
1790
|
+
* door fences probe the cwd's bindings first and never reach here there), then
|
|
1791
|
+
* the arguments, the door name, the artifact
|
|
1792
|
+
* (containment ladder, `.planning/` only, a seal-walked directory, a door
|
|
1793
|
+
* artifact filename), the start (anchor shape BEFORE it reaches git, then an
|
|
1794
|
+
* ancestor of HEAD, with the revision after the subcommand, T-16-21), the
|
|
1795
|
+
* phase, HEAD, the pin.
|
|
1796
|
+
*
|
|
1797
|
+
* APPEND, NEVER REWRITE: existing content is kept byte for byte and the section
|
|
1798
|
+
* goes after it; an absent artifact is created with a one-line header. An
|
|
1799
|
+
* artifact that already carries a door record is REFUSED rather than given a
|
|
1800
|
+
* second one, because two sections are a duplicate the reader rejects, which
|
|
1801
|
+
* would leave the door with no readable record at all. The composed text is
|
|
1802
|
+
* read back with `parseDoorRecord` before anything is written, so the verb can
|
|
1803
|
+
* never write a record its own reader refuses (an unclosed fence above the
|
|
1804
|
+
* section, say), and the write is a temporary sibling renamed into place.
|
|
1805
|
+
*
|
|
1806
|
+
* Every refusal is an error (exit 1): a door that cannot record halts its
|
|
1807
|
+
* fence instead of passing unrecorded.
|
|
1808
|
+
*/
|
|
1809
|
+
function doorRecordVerb({ cwd, rest, raw, error, c, exec }) {
|
|
1810
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1811
|
+
const owner = require('./vault.cjs');
|
|
1812
|
+
if (owner.vaultProducerAdmissionVerdict(cwd).state !== 'on')
|
|
1813
|
+
return;
|
|
1814
|
+
const usage = 'Usage: vault door-record --begin --door <name> [--raw], then vault door-record --door <name> --artifact <path under .planning/> [--phase <N|dir>] [--raw].';
|
|
1815
|
+
const values = {};
|
|
1816
|
+
let begin = false;
|
|
1817
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1818
|
+
const a = rest[i];
|
|
1819
|
+
if (a === '--raw')
|
|
1820
|
+
continue;
|
|
1821
|
+
if (a === '--begin') {
|
|
1822
|
+
begin = true;
|
|
1823
|
+
continue;
|
|
1824
|
+
}
|
|
1825
|
+
if (DOOR_RECORD_FLAGS.includes(a)) {
|
|
1826
|
+
const v = rest[i + 1];
|
|
1827
|
+
if (v === undefined || v.startsWith('--')) {
|
|
1828
|
+
error(`${a} needs a value. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (values[a] !== undefined) {
|
|
1832
|
+
error(`${a} was given more than once. ${usage}`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1833
|
+
return;
|
|
1834
|
+
}
|
|
1835
|
+
values[a] = v;
|
|
1836
|
+
i += 1;
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
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);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
if (values['--door'] === undefined) {
|
|
1843
|
+
error(`vault door-record needs --door. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
const door = values['--door'];
|
|
1847
|
+
if (!vaultSeal.DOOR_NAMES.includes(door)) {
|
|
1848
|
+
error(`"${boundedPath(door)}" is not a door that writes a record. Allowed doors: ${vaultSeal.DOOR_NAMES.join(', ')}.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
if (begin) {
|
|
1852
|
+
doorBegin({ cwd, door, extra: Object.keys(values).filter((k) => k !== '--door'), raw, error, c, exec, usage });
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
if (values['--artifact'] === undefined) {
|
|
1856
|
+
error(`vault door-record needs --artifact. ${usage}`, ERROR_REASON.SDK_MISSING_ARG);
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
const located = resolvePlanningPath(cwd, values['--artifact'], 'artifact');
|
|
1860
|
+
if (!located.ok) {
|
|
1861
|
+
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);
|
|
1862
|
+
return;
|
|
1863
|
+
}
|
|
1864
|
+
if (!isDoorArtifactLocation(located.rel)) {
|
|
1865
|
+
error(`The door artifact "${boundedPath(located.rel)}" is not in a phase directory, a quick-task directory or .planning/audits, ` +
|
|
1866
|
+
'the directories the seal readers walk; a record anywhere else would never be read.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
// 16 review IN-02: the readers still recognise a `-VALIDATION.md` door
|
|
1870
|
+
// artifact (a legacy record stays readable), but the verb never WRITES one:
|
|
1871
|
+
// a `*-VALIDATION.md` file makes a phase read as validated (D-79 (3)).
|
|
1872
|
+
const artifactName = node_path_1.default.basename(located.abs);
|
|
1873
|
+
if (!vaultSeal.DOOR_ARTIFACT_FILENAME_RE.test(artifactName) || /(^|-)VALIDATION\.md$/.test(artifactName)) {
|
|
1874
|
+
error(`The door artifact "${boundedPath(artifactName)}" does not end in -REVIEW-FIX.md, -ADD-TESTS.md or -AUDIT-FIX.md, ` +
|
|
1875
|
+
'the door artifact names the verb writes (a -VALIDATION.md file would make the phase read as validated).', ERROR_REASON.SDK_FAIL_FAST);
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
// The start is the one this door's `--begin` recorded in `.planning/`, and
|
|
1879
|
+
// only that (16 review iteration 3 CR-02, D-103: `--start` is gone), so no
|
|
1880
|
+
// shell variable has to survive between the two steps, the agent never types
|
|
1881
|
+
// a sha, and no argument can move the start. It reaches git only after it has
|
|
1882
|
+
// the anchor shape, and only AFTER the subcommand and its flag (T-16-21).
|
|
1883
|
+
const recorded = vaultSeal.readDoorStart(node_path_1.default.join(located.root, '.planning'), door);
|
|
1884
|
+
if (recorded === null) {
|
|
1885
|
+
error(`No start was recorded for the ${door} door, so its range is unknown and the record was not written. `
|
|
1886
|
+
+ `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);
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
const start = recorded.start;
|
|
1890
|
+
if (exec(['merge-base', '--is-ancestor', start, 'HEAD'], { cwd }).exitCode !== 0) {
|
|
1891
|
+
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. `
|
|
1892
|
+
+ `Run \`pp-tools vault door-record --begin --door ${door}\` again, then redo the door's work.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const headRun = exec(['rev-parse', 'HEAD'], { cwd });
|
|
1896
|
+
const end = headRun.exitCode === 0 ? headRun.stdout.trim() : '';
|
|
1897
|
+
if (!vaultSeal.ANCHOR_SHA_RE.test(end)) {
|
|
1898
|
+
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);
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
let phase = null;
|
|
1902
|
+
if (values['--phase'] !== undefined) {
|
|
1903
|
+
const resolved = owner.resolveTracePhaseDir(located.root, cwd, values['--phase']);
|
|
1904
|
+
if (!resolved) {
|
|
1905
|
+
error(`--phase "${boundedPath(values['--phase'])}" is neither a phase directory inside the project nor a phase the locator resolves.`, ERROR_REASON.SDK_FAIL_FAST);
|
|
1906
|
+
return;
|
|
1907
|
+
}
|
|
1908
|
+
phase = doorText(resolved.rel, { token: true });
|
|
1909
|
+
}
|
|
1910
|
+
if (!owner.isVaultGateActive(cwd)) {
|
|
1911
|
+
error('The vault is off for this project, so there is no vault record to write.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
// The record grammar keeps its `mode` field; standards is the only mode.
|
|
1915
|
+
const mode = 'standards';
|
|
1916
|
+
const pin = doorVaultPin(cwd, owner);
|
|
1917
|
+
let existing = '';
|
|
1918
|
+
if (node_fs_1.default.existsSync(located.abs)) {
|
|
1919
|
+
const size = node_fs_1.default.statSync(located.abs).size;
|
|
1920
|
+
if (size > vaultSeal.TRACE_ARTIFACT_BYTE_CAP) {
|
|
1921
|
+
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);
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
existing = node_fs_1.default.readFileSync(located.abs, 'utf8');
|
|
1925
|
+
const prior = vaultSeal.parseDoorRecord(existing);
|
|
1926
|
+
if (!prior.ok || prior.record !== null) {
|
|
1927
|
+
error(`The door artifact "${boundedPath(located.rel)}" already carries a ${vaultSeal.DOOR_RECORD_HEADING} section; a second one would make both unreadable. ` +
|
|
1928
|
+
'Write the door\'s artifact afresh for this run, then record it.', ERROR_REASON.SDK_FAIL_FAST);
|
|
1929
|
+
return;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
let section;
|
|
1933
|
+
try {
|
|
1934
|
+
section = vaultSeal.formatDoorRecord({
|
|
1935
|
+
door: door,
|
|
1936
|
+
mode,
|
|
1937
|
+
vault: pin.vault,
|
|
1938
|
+
degraded: pin.degraded,
|
|
1939
|
+
phase,
|
|
1940
|
+
startCommit: start,
|
|
1941
|
+
endCommit: end,
|
|
1942
|
+
recordedAt: new Date().toISOString(),
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
catch (e) {
|
|
1946
|
+
error(`The door record could not be composed: ${boundedPath(e instanceof Error ? e.message : String(e))}`, ERROR_REASON.UNKNOWN);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const separator = existing === '' ? '' : existing.endsWith('\n\n') ? '' : existing.endsWith('\n') ? '\n' : '\n\n';
|
|
1950
|
+
const next = existing === '' ? `# ${door} door record\n\n${section}` : `${existing}${separator}${section}`;
|
|
1951
|
+
const readBack = vaultSeal.parseDoorRecord(next);
|
|
1952
|
+
if (!readBack.ok || readBack.record === null || readBack.record.startCommit !== start || readBack.record.endCommit !== end) {
|
|
1953
|
+
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);
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
// D-100 (16 review iteration 2 WR-01): consume the `--begin` start BEFORE the
|
|
1957
|
+
// record is written, after every refusal above. A start that cannot be
|
|
1958
|
+
// consumed refuses the record; a record whose write then fails leaves the
|
|
1959
|
+
// door with no start, so a retry halts naming `--begin` — both arms leave
|
|
1960
|
+
// commits uncovered (named by SEAL-01), never a range wider than one run.
|
|
1961
|
+
{
|
|
1962
|
+
let consumed = false;
|
|
1963
|
+
try {
|
|
1964
|
+
consumed = vaultSeal.consumeDoorStart(node_path_1.default.join(located.root, '.planning'), door, start);
|
|
1965
|
+
}
|
|
1966
|
+
catch {
|
|
1967
|
+
consumed = false;
|
|
1968
|
+
}
|
|
1969
|
+
if (!consumed) {
|
|
1970
|
+
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. `
|
|
1971
|
+
+ `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);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
const tmp = `${located.abs}.${process.pid}.tmp`;
|
|
1976
|
+
try {
|
|
1977
|
+
node_fs_1.default.writeFileSync(tmp, next, 'utf8');
|
|
1978
|
+
node_fs_1.default.renameSync(tmp, located.abs);
|
|
1979
|
+
}
|
|
1980
|
+
catch {
|
|
1981
|
+
try {
|
|
1982
|
+
node_fs_1.default.unlinkSync(tmp);
|
|
1983
|
+
}
|
|
1984
|
+
catch { /* nothing to clean */ }
|
|
1985
|
+
error(`The door artifact "${boundedPath(located.rel)}" could not be written.`, ERROR_REASON.UNKNOWN);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const result = { ok: true, door, artifact: located.rel, mode, phase, startCommit: start, endCommit: end };
|
|
1989
|
+
c.output(result, true, raw ? JSON.stringify(result) : `Recorded the ${door} door in ${located.rel} (${start.slice(0, 12)}..${end.slice(0, 12)}).`);
|
|
793
1990
|
}
|
|
794
1991
|
module.exports = {
|
|
795
1992
|
routeVaultCommand,
|
|
1993
|
+
// 13-06: exported so the D-15 sweep test and the D-08 subcommand test read
|
|
1994
|
+
// the vocabulary from the running module instead of restating it.
|
|
796
1995
|
VAULT_SUBCOMMANDS,
|
|
797
|
-
//
|
|
798
|
-
//
|
|
799
|
-
//
|
|
1996
|
+
// 04-14: exported so the agreement and documentation tests read the SHAPE and
|
|
1997
|
+
// the WORDING from the running module rather than restating either. A test
|
|
1998
|
+
// that restated them would pass while the module drifted.
|
|
800
1999
|
SCAFFOLD_PLAN_ID_RE,
|
|
801
2000
|
SCAFFOLD_PLAN_ID_FORM,
|
|
802
|
-
|
|
2001
|
+
// 16-05 (D-29): the quick-task half both rejection messages append.
|
|
2002
|
+
SCAFFOLD_QUICK_ID_FORM,
|
|
2003
|
+
// 16-06: the anchor verbs, read by the tests from the running module.
|
|
2004
|
+
ANCHOR_VERBS,
|
|
2005
|
+
// 15-07: exported so the integration test pins it equal to the trace gate's
|
|
2006
|
+
// artifact read cap, which is the agreement its own comment states.
|
|
2007
|
+
STANDARDS_PLAN_READ_BYTES,
|
|
803
2008
|
};
|