@pareto4/pp-core 1.19.0 → 1.21.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.
Files changed (126) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/pp-nyquist-auditor.md +19 -14
  3. package/agents/pp-phase-researcher.md +10 -0
  4. package/agents/pp-plan-checker.md +59 -36
  5. package/agents/pp-planner.md +49 -53
  6. package/agents/pp-vault-reviewer.md +2 -0
  7. package/agents/pp-verifier.md +15 -60
  8. package/commands/pp-autonomous.md +2 -1
  9. package/commands/pp-discovery.md +3 -2
  10. package/commands/pp-plan-phase.md +2 -1
  11. package/commands/pp-plan-review-convergence.md +2 -1
  12. package/gemini-extension.json +1 -1
  13. package/package.json +6 -3
  14. package/pareto-skills/_shared/pp-claude-md-protocol.md +1 -1
  15. package/pareto-skills/pp-project-setup/SKILL.md +18 -343
  16. package/pp-core/bin/lib/capability-activation.cjs +77 -0
  17. package/pp-core/bin/lib/capability-registry.cjs +15 -15
  18. package/pp-core/bin/lib/capability-state.cjs +8 -18
  19. package/pp-core/bin/lib/ceremony.cjs +1342 -0
  20. package/pp-core/bin/lib/check-command-router.cjs +62 -1
  21. package/pp-core/bin/lib/commands.cjs +29 -3
  22. package/pp-core/bin/lib/config.cjs +5 -0
  23. package/pp-core/bin/lib/discovery-access.cjs +276 -0
  24. package/pp-core/bin/lib/discovery-charter.cjs +664 -0
  25. package/pp-core/bin/lib/discovery-deviations.cjs +264 -0
  26. package/pp-core/bin/lib/discovery-entities.cjs +323 -0
  27. package/pp-core/bin/lib/discovery-for-phase.cjs +628 -0
  28. package/pp-core/bin/lib/discovery-integrations.cjs +286 -0
  29. package/pp-core/bin/lib/discovery-migration.cjs +246 -0
  30. package/pp-core/bin/lib/discovery-phases.cjs +233 -0
  31. package/pp-core/bin/lib/discovery-processes.cjs +355 -0
  32. package/pp-core/bin/lib/discovery-research-track.cjs +5 -4
  33. package/pp-core/bin/lib/discovery-rollup.cjs +8 -7
  34. package/pp-core/bin/lib/discovery-rules.cjs +216 -0
  35. package/pp-core/bin/lib/discovery-screens.cjs +1 -1
  36. package/pp-core/bin/lib/discovery-session-check.cjs +264 -0
  37. package/pp-core/bin/lib/discovery-stories.cjs +273 -0
  38. package/pp-core/bin/lib/discovery-sweep.cjs +1 -1
  39. package/pp-core/bin/lib/discovery-table.cjs +55 -0
  40. package/pp-core/bin/lib/init.cjs +50 -1
  41. package/pp-core/bin/lib/io.cjs +24 -0
  42. package/pp-core/bin/lib/loop-resolver.cjs +26 -12
  43. package/pp-core/bin/lib/phase.cjs +39 -3
  44. package/pp-core/bin/lib/plan-sizing.cjs +346 -0
  45. package/pp-core/bin/lib/test-substance.cjs +3190 -0
  46. package/pp-core/bin/lib/timeline.cjs +605 -0
  47. package/pp-core/bin/lib/vault-standards-review.cjs +50 -2
  48. package/pp-core/bin/pp-tools.cjs +214 -5
  49. package/pp-core/bin/shared/ceremony-gates.json +17 -0
  50. package/pp-core/bin/shared/config-schema.manifest.json +1 -0
  51. package/pp-core/references/ceremony.md +81 -0
  52. package/pp-core/references/discovery-access.md +112 -0
  53. package/pp-core/references/discovery-build-consumption.md +256 -0
  54. package/pp-core/references/discovery-charter.md +127 -0
  55. package/pp-core/references/discovery-data-model.md +145 -0
  56. package/pp-core/references/discovery-id-conventions.md +50 -14
  57. package/pp-core/references/discovery-integrations.md +151 -0
  58. package/pp-core/references/discovery-processes.md +190 -0
  59. package/pp-core/references/discovery-rebaseline.md +2 -2
  60. package/pp-core/references/discovery-research-track.md +5 -6
  61. package/pp-core/references/discovery-session-output.md +70 -0
  62. package/pp-core/references/discovery-standards.md +107 -0
  63. package/pp-core/references/discovery-traceability.md +2 -2
  64. package/pp-core/references/few-shot-examples/plan-checker.md +8 -8
  65. package/pp-core/references/phase-timeline.md +107 -0
  66. package/pp-core/references/planner-antipatterns.md +32 -0
  67. package/pp-core/references/planner-discovery-extract.md +35 -0
  68. package/pp-core/references/planner-guidance.md +17 -14
  69. package/pp-core/references/planner-revision.md +1 -1
  70. package/pp-core/references/planning-config.md +3 -2
  71. package/pp-core/references/test-substance.md +324 -0
  72. package/pp-core/references/verifier-discovery-conformance.md +36 -0
  73. package/pp-core/references/verifier-stub-patterns.md +51 -0
  74. package/pp-core/templates/VALIDATION.md +1 -0
  75. package/pp-core/templates/phase-prompt.md +19 -7
  76. package/pp-core/templates/project-charter.md +161 -100
  77. package/pp-core/templates/project.md +3 -3
  78. package/pp-core/templates/requirements.md +18 -0
  79. package/pp-core/templates/roadmap.md +1 -1
  80. package/pp-core/templates/verification-report.md +17 -1
  81. package/pp-core/workflows/audit-milestone.md +4 -1
  82. package/pp-core/workflows/autonomous.md +11 -8
  83. package/pp-core/workflows/code-review.md +15 -6
  84. package/pp-core/workflows/discovery-sprint/charter.md +114 -0
  85. package/pp-core/workflows/discovery-sprint/prefill.md +1 -1
  86. package/pp-core/workflows/discovery-sprint/prototype.md +4 -4
  87. package/pp-core/workflows/discovery-sprint/session-13-gates.md +10 -0
  88. package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +5 -2
  89. package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +27 -5
  90. package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +56 -31
  91. package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +1 -1
  92. package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +42 -19
  93. package/pp-core/workflows/discovery-sprint/sessions/06-security.md +27 -12
  94. package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +41 -23
  95. package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +50 -23
  96. package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +26 -11
  97. package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +28 -9
  98. package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +21 -0
  99. package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +24 -9
  100. package/pp-core/workflows/discovery-sprint.md +12 -15
  101. package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +80 -29
  102. package/pp-core/workflows/discovery-to-build/steps/{screen-inventory-gate.md → handoff-gates.md} +111 -3
  103. package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +12 -9
  104. package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +27 -26
  105. package/pp-core/workflows/discovery-to-build.md +70 -34
  106. package/pp-core/workflows/discuss-phase/discovery.md +83 -0
  107. package/pp-core/workflows/discuss-phase/templates/context.md +4 -0
  108. package/pp-core/workflows/discuss-phase.md +10 -7
  109. package/pp-core/workflows/execute-phase/steps/check-query-gate.md +7 -7
  110. package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -2
  111. package/pp-core/workflows/execute-phase.md +14 -3
  112. package/pp-core/workflows/new-project.md +3 -3
  113. package/pp-core/workflows/plan-phase/steps/chunked-planning.md +7 -3
  114. package/pp-core/workflows/plan-phase/steps/discovery-extract.md +41 -0
  115. package/pp-core/workflows/plan-phase/steps/prd-express.md +104 -0
  116. package/pp-core/workflows/plan-phase.md +62 -124
  117. package/pp-core/workflows/plan-review-convergence.md +6 -3
  118. package/pp-core/workflows/ship.md +2 -2
  119. package/pp-core/workflows/stats.md +19 -4
  120. package/pp-core/workflows/validate-phase.md +19 -4
  121. package/pp-core/workflows/vault-review.md +5 -0
  122. package/pp-core/workflows/verify-phase.md +1 -1
  123. package/pp-core/workflows/verify-work.md +1 -1
  124. package/scripts/gen-capability-registry.cjs +43 -45
  125. package/scripts/run-tests.cjs +54 -30
  126. package/pp-core/workflows/discovery-sprint/templates/context-template.md +0 -272
@@ -0,0 +1,1342 @@
1
+ "use strict";
2
+ /**
3
+ * Ceremony: the pure phase classifier core (Phase 37).
4
+ *
5
+ * ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
6
+ * compiles to the gitignored `pp-core/bin/lib/ceremony.cjs`; do NOT hand-edit
7
+ * the compiled output.
8
+ *
9
+ * Before any agent runs, a phase is classified by three questions: is a trust
10
+ * boundary crossed, is there a codebase to match, and is this serial
11
+ * single-concern work (D-01..D-04). The answers give a tier (D-05) and a
12
+ * decision for every gate (D-06..D-08); a forced full mode runs every gate as
13
+ * config says (D-09).
14
+ *
15
+ * Everything in this file is a pure function of its arguments: no file, git,
16
+ * config, environment or clock read, so the verdict is a function of its
17
+ * inputs (CLAS-07) and identical inputs serialize byte-identically (SC1). The
18
+ * module takes no tenant-identifying input. The I/O shell at the bottom of the
19
+ * file (marked) gathers the inputs and serves the `ceremony classify|get` CLI;
20
+ * everything above that marker stays pure.
21
+ *
22
+ * `serializeCeremony` writes the `{padded}-CEREMONY.md` text (D-11): the
23
+ * machine contract in the frontmatter, then the same table the CLI prints
24
+ * (D-13). `parseCeremony` reads it back and returns null on any contract
25
+ * violation, so a corrupt file falls back to full rather than to less ceremony.
26
+ */
27
+ var __importDefault = (this && this.__importDefault) || function (mod) {
28
+ return (mod && mod.__esModule) ? mod : { "default": mod };
29
+ };
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.EXCLUDED_PATH_SEGMENTS = exports.SOURCE_EXTENSIONS = exports.BACKEND_TERMS = exports.TRUST_TERMS = exports.CEREMONY_GATES = exports.CEREMONY_SCHEMA_VERSION = exports.MAX_SCAN_CHARS = exports.MAX_SUCCESS_CRITERIA = exports.MAX_REQ_IDS = exports.MIN_SOURCE_FILES = void 0;
32
+ exports.scanTrustTerms = scanTrustTerms;
33
+ exports.sanitizeEvidence = sanitizeEvidence;
34
+ exports.classify = classify;
35
+ exports.defaultFullVerdict = defaultFullVerdict;
36
+ exports.renderCeremonyTable = renderCeremonyTable;
37
+ exports.serializeCeremony = serializeCeremony;
38
+ exports.parseCeremony = parseCeremony;
39
+ exports.deriveCeremonyKeys = deriveCeremonyKeys;
40
+ exports.ceremonyGateOpen = ceremonyGateOpen;
41
+ exports.resolveGateConfig = resolveGateConfig;
42
+ exports.parseReqIds = parseReqIds;
43
+ exports.gatherInputs = gatherInputs;
44
+ exports.readRecordedVerdict = readRecordedVerdict;
45
+ exports.effectiveVerdict = effectiveVerdict;
46
+ exports.cmdCeremony = cmdCeremony;
47
+ const node_fs_1 = __importDefault(require("node:fs"));
48
+ const node_path_1 = __importDefault(require("node:path"));
49
+ const node_child_process_1 = require("node:child_process");
50
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
51
+ const frontmatterMod = require("./frontmatter.cjs");
52
+ const security_cjs_1 = require("./security.cjs");
53
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
54
+ const io = require("./io.cjs");
55
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
56
+ const phaseScopeMod = require("./phase-scope.cjs");
57
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
58
+ const phaseIdMod = require("./phase-id.cjs");
59
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
60
+ const planningWorkspaceMod = require("./planning-workspace.cjs");
61
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
62
+ const roadmapMod = require("./roadmap.cjs");
63
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
64
+ const configLoaderMod = require("./config-loader.cjs");
65
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
66
+ const capabilityActivationMod = require("./capability-activation.cjs");
67
+ const { extractFrontmatter, spliceFrontmatter } = frontmatterMod;
68
+ const { output, ERROR_REASON } = io;
69
+ const { resolvePhaseForRead, resolvePhaseForWrite } = phaseScopeMod;
70
+ const { extractPhaseToken } = phaseIdMod;
71
+ const { planningPaths } = planningWorkspaceMod;
72
+ const { getRoadmapPhaseWithFallback } = roadmapMod;
73
+ const { loadConfig } = configLoaderMod;
74
+ const { resolveConfigKey } = capabilityActivationMod;
75
+ /** Explicitly typed so TypeScript narrows after a failing guard. */
76
+ function error(message, reason) {
77
+ return io.error(message, reason);
78
+ }
79
+ // ─── Thresholds and limits (named constants; D-03, D-04, T-37-03) ────────────
80
+ /** D-03: tracked source files outside `.planning/` that make a codebase to match. */
81
+ exports.MIN_SOURCE_FILES = 20;
82
+ /** D-04: at most this many requirement IDs for a serial single-concern phase. */
83
+ exports.MAX_REQ_IDS = 3;
84
+ /** D-04: at most this many success criteria for a serial single-concern phase. */
85
+ exports.MAX_SUCCESS_CRITERIA = 4;
86
+ /** T-37-03: each scanned source is truncated to this many characters. */
87
+ exports.MAX_SCAN_CHARS = 200000;
88
+ exports.CEREMONY_SCHEMA_VERSION = 1;
89
+ const MAX_EVIDENCE_CHARS = 160;
90
+ const MAX_LISTED_TERMS = 6;
91
+ // Resolve the shared vocabulary through a candidate list so the module works in
92
+ // every layout: the installed `pp-core/bin/shared/` first, then the source tree.
93
+ const _require = require;
94
+ const _gateCandidates = [
95
+ node_path_1.default.resolve(__dirname, '..', 'shared', 'ceremony-gates.json'),
96
+ node_path_1.default.resolve(__dirname, '..', '..', 'pp-core', 'bin', 'shared', 'ceremony-gates.json'),
97
+ ];
98
+ function loadGateVocabulary() {
99
+ let lastErr = null;
100
+ for (const candidate of _gateCandidates) {
101
+ try {
102
+ return _require(candidate);
103
+ }
104
+ catch (e) {
105
+ const code = e?.code;
106
+ if (code !== 'MODULE_NOT_FOUND' && code !== 'ENOENT')
107
+ throw e;
108
+ lastErr = e;
109
+ }
110
+ }
111
+ throw new Error(`ceremony-gates.json not found. Tried:\n${_gateCandidates.map((p) => ` ${p}`).join('\n')}\nLast error: ${String(lastErr?.message)}`);
112
+ }
113
+ const _vocabulary = loadGateVocabulary();
114
+ /** The gate keys in verdict-table order. */
115
+ exports.CEREMONY_GATES = _vocabulary.gates.map((g) => g.key);
116
+ const GATE_LABELS = Object.fromEntries(_vocabulary.gates.map((g) => [g.key, g.label]));
117
+ // ─── Term sets (D-02, D-04) ──────────────────────────────────────────────────
118
+ /**
119
+ * D-02: trust-boundary terms. A heuristic that only ever pushes ceremony UP:
120
+ * a phase worded without any of these terms reads as no trust boundary, and
121
+ * `--full`, `workflow.ceremony: full` or a ROADMAP `**Ceremony:** full` line
122
+ * restores the full stack (residual risk, stated in references/ceremony.md).
123
+ * A bare "token" is deliberately absent: pp-core's own phases talk about
124
+ * context tokens.
125
+ */
126
+ exports.TRUST_TERMS = [
127
+ {
128
+ category: 'authentication and authorization',
129
+ terms: [
130
+ 'auth', 'authentication', 'authorization', 'login', 'logout', 'sign-in', 'session',
131
+ 'password', 'credential', 'OAuth', 'OIDC', 'SSO', 'JWT', 'bearer', 'API key',
132
+ 'access token', 'refresh token', 'permission', 'role-based', 'RBAC', 'ACL', 'access control',
133
+ ],
134
+ },
135
+ {
136
+ category: 'secrets and crypto',
137
+ terms: ['secret', 'encrypt', 'decrypt', 'signing key', 'certificate'],
138
+ },
139
+ {
140
+ category: 'personal and payment data',
141
+ terms: ['PII', 'personal data', 'GDPR', 'payment', 'card', 'billing'],
142
+ },
143
+ {
144
+ category: 'untrusted input',
145
+ terms: [
146
+ 'upload', 'webhook', 'user input', 'untrusted', 'external API', 'third-party callback', 'deserialize',
147
+ 'SQL', 'shell exec', 'shell command', 'command injection', 'CORS', 'CSRF', 'cookie', 'multi-tenant',
148
+ 'tenant isolation',
149
+ ],
150
+ },
151
+ ];
152
+ /**
153
+ * CR-01: the word forms a term also matches, as a regex source without the word
154
+ * boundaries. A term not listed here matches its literal words plus a plural
155
+ * `s`. Each form is opt-in per term (a blanket suffix rule would let `auth` match
156
+ * "author"), and every alternation is flat, so matching stays linear (T-37-03).
157
+ * NEGATED (CR-01 iteration 2) lets "unauthorized", "unencrypted", "preauthorized",
158
+ * "reauthorize" and "non-authenticated" match: the word boundary stays in front of
159
+ * the prefix, and the stem after it is unchanged, so "author" still does not match.
160
+ */
161
+ const NEGATED = '(?:un-?|non-?|re-?|pre-?)?';
162
+ /**
163
+ * WR-02 (iteration 2): "log in" and "sign in" only in their authentication sense,
164
+ * so logging prose ("Write the logs in JSON", "structured logging in the
165
+ * executor", "commits signed in CI") stays unmatched. `authVerbForms` builds one
166
+ * term's alternation from seven named rules; each is tested on its own. Every
167
+ * lookaround is fixed or bounded, so matching stays linear (T-37-03).
168
+ */
169
+ /** The verb followed by with, to, using, via, as, through, or the end of the clause. */
170
+ const AUTH_VERB_AFTER = '(?=[ \\t]{1,8}(?:with|to|using|via|as|through)(?![A-Za-z0-9])|[.,;:!?)](?!\\S)|[ \\t]{0,8}(?:\\r?\\n|$))';
171
+ /** A person or account right before the verb ("users log in", "who signed in"). */
172
+ const AUTH_VERB_ACTOR = '(?<=(?<![A-Za-z0-9])(?:users?|customers?|members?|admins?|operators?|visitors?|employees?|staff|accounts?|people|who|you|they)[ \\t]{1,8})';
173
+ /** A modal, "to" or "allow" right before the base verb ("can log in", "to sign in", "allow log in"). */
174
+ const AUTH_VERB_MODAL = '(?<=(?<![A-Za-z0-9])(?:can|cannot|must|to|allows?|allowed|allowing)[ \\t]{1,8})';
175
+ /**
176
+ * CR-02 (iteration 3): the spaced participle ("logged in users", "not signed in"),
177
+ * unless a place or a format follows ("logged in the audit file", "signed in CI",
178
+ * "logged in JSON"). Over-match ("logged in STATE.md") only adds ceremony.
179
+ */
180
+ const AUTH_PARTICIPLE_NOT_PLACE = '(?![ \\t]{1,8}(?:CI|the|a|an|its|their|this|that|each|JSON|stdout|stderr)(?![A-Za-z0-9]))';
181
+ /** CR-02 (iteration 3): the spaced noun before a UI or flow word ("sign in page", "log in screen"). */
182
+ const AUTH_NOUN_UI = '(?=[ \\t-]{1,8}(?:pages?|screens?|forms?|flows?|buttons?|dialogs?|modals?|links?|prompts?|time|attempts?)(?![A-Za-z0-9]))';
183
+ /** The closed nouns, unless they name a log artefact ("login lines", "sign-in files"). */
184
+ const AUTH_NOUN_NOT_ARTEFACT = '(?![ \\t-]{1,8}(?:lines?|files?)(?![A-Za-z0-9]))';
185
+ function authVerbForms(stem, past, progressive) {
186
+ const verb = `${stem}(?:s|${past}|${progressive})?[ \\t-]+in`;
187
+ return '(?:' + [
188
+ `${verb}${AUTH_VERB_AFTER}`,
189
+ `${AUTH_VERB_ACTOR}${verb}`,
190
+ `${AUTH_VERB_MODAL}${stem}[ \\t-]+in`,
191
+ `${stem}${past}-in`,
192
+ `${stem}${past}[ \\t]+in${AUTH_PARTICIPLE_NOT_PLACE}`,
193
+ `${stem}[ \\t-]+in${AUTH_NOUN_UI}`,
194
+ `${stem}-?ins?${AUTH_NOUN_NOT_ARTEFACT}`,
195
+ ].join('|') + ')';
196
+ }
197
+ const TERM_FORMS = {
198
+ authentication: `${NEGATED}authenticat(?:e|es|ed|ing|ion|ions|or|ors)`,
199
+ authorization: `${NEGATED}authori[sz](?:e|es|ed|ing|ation|ations|er|ers)`,
200
+ login: authVerbForms('log', 'ged', 'ging'),
201
+ logout: '(?:log(?:s|ged|ging)?[\\s-]+out|log-?outs?)',
202
+ 'sign-in': authVerbForms('sign', 'ed', 'ing'),
203
+ encrypt: `${NEGATED}encrypt(?:s|ed|ing|ion|ions)?`,
204
+ decrypt: `${NEGATED}decrypt(?:s|ed|ing|ion|ions)?`,
205
+ upload: 'upload(?:s|ed|ing|er|ers)?',
206
+ deserialize: 'deseriali[sz](?:e|es|ed|ing|ation|ations|er|ers)',
207
+ };
208
+ /** D-04: a UI phase that also touches one of these is not single-concern. */
209
+ exports.BACKEND_TERMS = [
210
+ 'api', 'endpoint', 'database', 'schema', 'migration', 'server', 'backend', 'service',
211
+ ];
212
+ function escapeRegex(s) {
213
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
214
+ }
215
+ /**
216
+ * One case-insensitive regex per term (T-37-03): the term's TERM_FORMS entry, else
217
+ * the escaped literal words joined by `[\s-]+` and an optional plural `s`, with
218
+ * fixed lookarounds as the word boundary. No nested quantifier, so matching is
219
+ * linear in the (capped) source length.
220
+ */
221
+ function termRegex(term) {
222
+ const form = Object.prototype.hasOwnProperty.call(TERM_FORMS, term) ? TERM_FORMS[term] : null;
223
+ const body = form ?? `${term.split(/[\s-]+/).filter(Boolean).map(escapeRegex).join('[\\s-]+')}s?`;
224
+ return new RegExp(`(?<![A-Za-z0-9])${body}(?![A-Za-z0-9])`, 'i');
225
+ }
226
+ const TRUST_REGEXES = exports.TRUST_TERMS.flatMap((cat) => cat.terms).map((term) => ({ term, re: termRegex(term) }));
227
+ const BACKEND_REGEXES = exports.BACKEND_TERMS.map((term) => ({ term, re: termRegex(term) }));
228
+ function scanTerms(list, sources) {
229
+ const texts = [
230
+ ['roadmap', String(sources.roadmap ?? '').slice(0, exports.MAX_SCAN_CHARS)],
231
+ ['requirements', String(sources.requirements ?? '').slice(0, exports.MAX_SCAN_CHARS)],
232
+ ['context', String(sources.context ?? '').slice(0, exports.MAX_SCAN_CHARS)],
233
+ ];
234
+ const out = [];
235
+ for (const { term, re } of list) {
236
+ for (const [source, text] of texts) {
237
+ if (re.test(text)) {
238
+ out.push({ term, source });
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+ /** D-02: the trust terms found in the sources, in TRUST_TERMS order, deduplicated. */
246
+ function scanTrustTerms(sources) {
247
+ return scanTerms(TRUST_REGEXES, sources);
248
+ }
249
+ const QUESTIONS = ['trust_boundary', 'codebase_to_match', 'serial_single_concern'];
250
+ const QUESTION_LABELS = {
251
+ trust_boundary: 'Trust boundary crossed',
252
+ codebase_to_match: 'Codebase to match',
253
+ serial_single_concern: 'Serial single-concern',
254
+ };
255
+ const TIERS = ['light', 'standard', 'heavy'];
256
+ const MODES = ['proportional', 'full'];
257
+ const FORCED_BY = ['flag', 'config', 'roadmap'];
258
+ const DECISIONS = ['run', 'skip', 'off'];
259
+ const REVIEW_DEPTHS = ['quick', 'standard', 'deep'];
260
+ // ─── Evidence hygiene (T-37-02) ──────────────────────────────────────────────
261
+ /**
262
+ * One safe line for the verdict file and the printed table: no newline, no ANSI
263
+ * or control character, no leading quote or bracket (the frontmatter helper would
264
+ * strip or misread it), at most 160 characters; an empty result is `none`.
265
+ */
266
+ function sanitizeEvidence(value) {
267
+ let s = typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : '';
268
+ s = String((0, security_cjs_1.sanitizeForPrompt)(s) ?? '');
269
+ s = s.replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\u001b[@-Z\\-_]/g, '');
270
+ s = s.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').replace(/\s+/g, ' ').trim();
271
+ s = s.replace(/^["'`[{#\s]+/, '').replace(/["'\s]+$/, '');
272
+ if (s.length > MAX_EVIDENCE_CHARS) {
273
+ s = s.slice(0, MAX_EVIDENCE_CHARS).replace(/["'\s]+$/, '');
274
+ }
275
+ return s === '' ? 'none' : s;
276
+ }
277
+ // ─── Gate rows (D-06..D-09) ──────────────────────────────────────────────────
278
+ function normalizeDepth(depth) {
279
+ return REVIEW_DEPTHS.includes(depth) ? depth : 'standard';
280
+ }
281
+ function row(decision, reason, extra = {}) {
282
+ const out = { decision };
283
+ if (extra.depth !== undefined)
284
+ out.depth = extra.depth;
285
+ if (extra.rounds !== undefined)
286
+ out.rounds = extra.rounds;
287
+ if (extra.mode !== undefined)
288
+ out.mode = extra.mode;
289
+ out.reason = sanitizeEvidence(reason);
290
+ return out;
291
+ }
292
+ const OFF_KEY = {
293
+ research: 'workflow.research',
294
+ pattern_mapper: 'workflow.pattern_mapper',
295
+ security: 'workflow.security_enforcement',
296
+ plan_check: 'workflow.plan_check',
297
+ code_review: 'workflow.code_review',
298
+ verifier: 'workflow.verifier',
299
+ nyquist: 'workflow.nyquist_validation',
300
+ ui_review: 'workflow.ui_review',
301
+ ui: 'workflow.ui_phase and workflow.ui_safety_gate',
302
+ vault: 'vault',
303
+ };
304
+ function off(key) {
305
+ return row('off', `${OFF_KEY[key] ?? key} is off in config`);
306
+ }
307
+ function buildGates(ctx) {
308
+ const { full, tier, trust, codebase, flags, cfg } = ctx;
309
+ const prop = !full;
310
+ const light = prop && tier === 'light';
311
+ const reviewDepth = normalizeDepth(cfg.code_review_depth);
312
+ const gates = {};
313
+ // research: a flag decides first, then config, then the codebase rule.
314
+ if (flags.skipResearch)
315
+ gates.research = row('skip', 'flag --skip-research');
316
+ else if (flags.research)
317
+ gates.research = row('run', 'flag --research');
318
+ else if (!cfg.research)
319
+ gates.research = off('research');
320
+ else if (prop && !codebase) {
321
+ // WR-05: plan-time VALIDATION.md is built from RESEARCH.md, so it skips with research.
322
+ // The checker's Dimension 8 does not: it runs without RESEARCH.md (Phase 38, D-05).
323
+ gates.research = row('skip', cfg.nyquist
324
+ ? 'no codebase to match; plan-time VALIDATION.md skips with it'
325
+ : 'no codebase to match');
326
+ }
327
+ else
328
+ gates.research = row('run', full ? 'full mode' : 'codebase to match');
329
+ if (!cfg.pattern_mapper)
330
+ gates.pattern_mapper = off('pattern_mapper');
331
+ else if (prop && !codebase)
332
+ gates.pattern_mapper = row('skip', 'no codebase to match');
333
+ else
334
+ gates.pattern_mapper = row('run', full ? 'full mode' : 'codebase to match');
335
+ if (!cfg.security)
336
+ gates.threat_model = off('security');
337
+ else if (prop && !trust)
338
+ gates.threat_model = row('skip', 'no trust boundary crossed');
339
+ else
340
+ gates.threat_model = row('run', full ? 'full mode' : 'trust boundary crossed');
341
+ if (flags.skipVerify)
342
+ gates.plan_check = row('skip', 'flag --skip-verify');
343
+ else if (!cfg.plan_check)
344
+ gates.plan_check = off('plan_check');
345
+ else if (light) {
346
+ gates.plan_check = row('run', 'light tier: one blockers-only round, one targeted revision, no re-check', { rounds: 1, mode: 'blockers-only' });
347
+ }
348
+ else {
349
+ gates.plan_check = row('run', full ? 'full mode: up to 3 rounds' : 'standard or heavy tier: up to 3 rounds', { rounds: 3, mode: 'standard' });
350
+ }
351
+ if (!cfg.code_review)
352
+ gates.code_review = off('code_review');
353
+ else if (light)
354
+ gates.code_review = row('run', 'light tier: quick depth', { depth: 'quick' });
355
+ else
356
+ gates.code_review = row('run', 'configured depth', { depth: reviewDepth });
357
+ if (!cfg.verifier)
358
+ gates.verifier = off('verifier');
359
+ else if (light && !trust)
360
+ gates.verifier = row('run', 'light tier: skips the data-flow trace and spot-checks', { depth: 'light' });
361
+ else
362
+ gates.verifier = row('run', full ? 'full mode' : 'standard, heavy tier or trust boundary: full depth', { depth: 'full' });
363
+ if (!cfg.security)
364
+ gates.security_review = off('security');
365
+ else if (prop && !trust)
366
+ gates.security_review = row('skip', 'no trust boundary crossed');
367
+ else
368
+ gates.security_review = row('run', full ? 'full mode' : 'trust boundary crossed');
369
+ if (!cfg.nyquist)
370
+ gates.nyquist = off('nyquist');
371
+ else if (light)
372
+ gates.nyquist = row('skip', 'light tier');
373
+ else
374
+ gates.nyquist = row('run', full ? 'full mode' : 'standard or heavy tier');
375
+ if (!cfg.ui_review)
376
+ gates.ui_review = off('ui_review');
377
+ else if (light)
378
+ gates.ui_review = row('skip', 'light tier');
379
+ else
380
+ gates.ui_review = row('run', full ? 'full mode' : 'standard or heavy tier');
381
+ if (!cfg.ui_phase && !cfg.ui_safety_gate)
382
+ gates.ui = off('ui');
383
+ else {
384
+ const f = ctx.frontend === null ? '' : ` (frontend: ${ctx.frontend ? 'yes' : 'no'})`;
385
+ gates.ui = row('run', `follows frontend detection${f}`);
386
+ }
387
+ if (!cfg.vault)
388
+ gates.vault = off('vault');
389
+ else
390
+ gates.vault = row('run', 'client standards govern every phase');
391
+ // Return in table order whatever the order above.
392
+ const ordered = {};
393
+ for (const key of exports.CEREMONY_GATES)
394
+ ordered[key] = gates[key];
395
+ return ordered;
396
+ }
397
+ // ─── Classifier ──────────────────────────────────────────────────────────────
398
+ function prefixOf(id) {
399
+ const i = id.lastIndexOf('-');
400
+ return i > 0 ? id.slice(0, i) : id;
401
+ }
402
+ function plural(n, one, many) {
403
+ return n === 1 ? one : many;
404
+ }
405
+ /** The three questions, the tier, every gate row and the warnings. Pure. */
406
+ function classify(i) {
407
+ const sources = {
408
+ roadmap: String(i.roadmapText ?? ''),
409
+ requirements: String(i.requirementText ?? ''),
410
+ context: String(i.contextText ?? ''),
411
+ };
412
+ // CR-02: input the shell could not read or parse never reads as a small
413
+ // phase: each gap makes it not single-concern (so at least standard) and names
414
+ // itself in the evidence and the warnings. WR-03 (iteration 2): an explicit
415
+ // `TBD`, `None` or `N/A` claims nothing, so it and a /pp-phase add|insert
416
+ // template plan standard, not heavy. CR-01 (iteration 3): a missing or
417
+ // unreadable section, or (WR-01) a mapped ID with no REQUIREMENTS.md line,
418
+ // cannot rule a trust boundary out, so it answers crossed.
419
+ const reqIds = Array.isArray(i.reqIds) ? i.reqIds.map(String) : [];
420
+ const criteria = Math.max(0, Math.trunc(Number(i.successCriteria) || 0));
421
+ const withoutText = Array.isArray(i.requirementIdsWithoutText) ? i.requirementIdsWithoutText.map(String) : [];
422
+ const sectionMissing = sources.roadmap.trim() === '';
423
+ const singleGaps = [];
424
+ if (sectionMissing)
425
+ singleGaps.push('ROADMAP phase section not found');
426
+ if (reqIds.length === 0)
427
+ singleGaps.push('no requirement IDs parsed');
428
+ if (withoutText.length > 0)
429
+ singleGaps.push(`no REQUIREMENTS.md line for ${withoutText.join(', ')}`);
430
+ if (!sectionMissing && criteria === 0)
431
+ singleGaps.push('no success criteria parsed');
432
+ if (i.frontend === null)
433
+ singleGaps.push('frontend detection failed');
434
+ // D-02 trust boundary: a matched term, or input that cannot rule one out.
435
+ const trustHits = scanTrustTerms(sources);
436
+ const trustGaps = [];
437
+ if (sectionMissing)
438
+ trustGaps.push('phase section unreadable');
439
+ // WR-01 (iteration 3): a named ID whose text was never read is unread input too.
440
+ if (withoutText.length > 0) {
441
+ const extra = withoutText.length > MAX_LISTED_TERMS ? `, +${withoutText.length - MAX_LISTED_TERMS} more` : '';
442
+ trustGaps.push(`requirement text unread for ${withoutText.slice(0, MAX_LISTED_TERMS).join(', ')}${extra}`);
443
+ }
444
+ const trust = trustHits.length > 0 || trustGaps.length > 0;
445
+ const listed = trustHits.slice(0, MAX_LISTED_TERMS).map((m) => `${m.term} (${m.source})`);
446
+ const more = trustHits.length > MAX_LISTED_TERMS ? `, +${trustHits.length - MAX_LISTED_TERMS} more` : '';
447
+ const trustEvidence = trustHits.length > 0
448
+ ? `matched: ${listed.join(', ')}${more}`
449
+ : trustGaps.length > 0
450
+ ? `${trustGaps.join('; ')} — failing toward ceremony`
451
+ : singleGaps.length > 0
452
+ ? 'no trust term in the input read; input incomplete (see warning)'
453
+ : 'no trust term in roadmap, requirements or context';
454
+ // D-03 codebase to match.
455
+ const completed = Math.max(0, Math.trunc(Number(i.completedOtherPhases) || 0));
456
+ const archived = Math.max(0, Math.trunc(Number(i.archivedMilestones) || 0));
457
+ const files = i.trackedSourceFiles;
458
+ const unavailable = files === null || files === undefined;
459
+ const codebase = unavailable || files >= exports.MIN_SOURCE_FILES || completed > 0 || archived > 0;
460
+ const tail = `${completed} completed other ${plural(completed, 'phase', 'phases')}; ${archived} archived ${plural(archived, 'milestone', 'milestones')}`;
461
+ const codebaseEvidence = unavailable
462
+ ? `git inventory unavailable; ${tail}`
463
+ : `${files} tracked source ${plural(files, 'file', 'files')} (threshold ${exports.MIN_SOURCE_FILES}); ${tail}`;
464
+ // D-04 serial single-concern.
465
+ const prefixes = new Set(reqIds.map(prefixOf));
466
+ const backendHits = i.frontend ? scanTerms(BACKEND_REGEXES, sources).map((m) => m.term) : [];
467
+ const uiBackend = Boolean(i.frontend) && backendHits.length > 0;
468
+ const surface = i.frontend === null
469
+ ? 'unknown'
470
+ : !i.frontend ? 'none' : uiBackend ? `ui + backend (${backendHits.slice(0, 4).join(', ')})` : 'ui only';
471
+ const single = singleGaps.length === 0 &&
472
+ reqIds.length <= exports.MAX_REQ_IDS && prefixes.size <= 1 && criteria <= exports.MAX_SUCCESS_CRITERIA && !uiBackend;
473
+ const singleEvidence = `${reqIds.length} requirement ${plural(reqIds.length, 'ID', 'IDs')} (max ${exports.MAX_REQ_IDS}); ` +
474
+ `${prefixes.size} ${plural(prefixes.size, 'prefix', 'prefixes')} (max 1); ` +
475
+ `${criteria} success ${plural(criteria, 'criterion', 'criteria')} (max ${exports.MAX_SUCCESS_CRITERIA}); UI surface ${surface}` +
476
+ (singleGaps.length > 0 ? `; not single-concern: ${singleGaps.join('; ')}` : '');
477
+ // D-05 tier.
478
+ const tier = single && !trust ? 'light' : !single && trust ? 'heavy' : 'standard';
479
+ // D-09 mode.
480
+ const warnings = [];
481
+ if (singleGaps.length > 0)
482
+ warnings.push(`input incomplete, failing toward ceremony: ${singleGaps.join('; ')}`);
483
+ const forcedAll = [];
484
+ if (i.flags.full)
485
+ forcedAll.push('flag');
486
+ const cm = i.configMode;
487
+ if (cm !== undefined && cm !== null && cm !== 'proportional') {
488
+ forcedAll.push('config');
489
+ if (cm !== 'full') {
490
+ warnings.push(`workflow.ceremony '${sanitizeEvidence(cm)}' is not proportional or full; treated as full`);
491
+ }
492
+ }
493
+ if (i.roadmapForcesFull)
494
+ forcedAll.push('roadmap');
495
+ else if (typeof i.roadmapCeremonyIgnored === 'string') {
496
+ warnings.push(`ROADMAP Ceremony line '${sanitizeEvidence(i.roadmapCeremonyIgnored)}' is not full; ignored`);
497
+ }
498
+ const full = forcedAll.length > 0;
499
+ if (!i.gateConfig.security && trust) {
500
+ warnings.push('security enforcement is off in config but a trust boundary is crossed');
501
+ }
502
+ const gates = buildGates({
503
+ full, tier, trust, codebase, frontend: i.frontend === null ? null : Boolean(i.frontend), flags: i.flags, cfg: i.gateConfig,
504
+ });
505
+ return {
506
+ schema_version: 1,
507
+ phase: String(i.phase),
508
+ mode: full ? 'full' : 'proportional',
509
+ forced_by: forcedAll.length > 0 ? forcedAll[0] : null,
510
+ forced_by_all: forcedAll,
511
+ tier,
512
+ answers: {
513
+ trust_boundary: { value: trust, evidence: sanitizeEvidence(trustEvidence) },
514
+ codebase_to_match: { value: codebase, evidence: sanitizeEvidence(codebaseEvidence) },
515
+ serial_single_concern: { value: single, evidence: sanitizeEvidence(singleEvidence) },
516
+ },
517
+ gates,
518
+ warnings,
519
+ };
520
+ }
521
+ /**
522
+ * D-12, D-15: the verdict that applies when none is recorded. Full mode, every
523
+ * enabled gate at full depth, so a missing verdict never lowers ceremony.
524
+ */
525
+ function defaultFullVerdict(phase, gateConfig) {
526
+ const none = 'no recorded verdict';
527
+ return {
528
+ schema_version: 1,
529
+ phase: String(phase),
530
+ mode: 'full',
531
+ forced_by: null,
532
+ forced_by_all: [],
533
+ tier: 'heavy',
534
+ answers: {
535
+ trust_boundary: { value: true, evidence: none },
536
+ codebase_to_match: { value: true, evidence: none },
537
+ serial_single_concern: { value: false, evidence: none },
538
+ },
539
+ gates: buildGates({
540
+ full: true,
541
+ tier: 'heavy',
542
+ trust: true,
543
+ codebase: true,
544
+ frontend: null,
545
+ flags: { full: false, research: false, skipResearch: false, skipVerify: false },
546
+ cfg: gateConfig,
547
+ }),
548
+ warnings: [],
549
+ };
550
+ }
551
+ // ─── Table (D-13) ────────────────────────────────────────────────────────────
552
+ function cell(s) {
553
+ return sanitizeEvidence(s).replace(/\|/g, '\\|');
554
+ }
555
+ function decisionText(g) {
556
+ const parts = [];
557
+ if (g.depth !== undefined)
558
+ parts.push(`depth ${g.depth}`);
559
+ if (g.rounds !== undefined)
560
+ parts.push(`rounds ${g.rounds}`);
561
+ if (g.mode !== undefined)
562
+ parts.push(`mode ${g.mode}`);
563
+ return parts.length > 0 ? `${g.decision} (${parts.join(', ')})` : g.decision;
564
+ }
565
+ /** Plain text, no ANSI: the header, the answers table and the gate table. */
566
+ function renderCeremonyTable(v) {
567
+ let mode = v.mode;
568
+ if (v.mode === 'full' && v.forced_by) {
569
+ const rest = (v.forced_by_all || []).filter((s) => s !== v.forced_by);
570
+ mode = `full, forced by ${v.forced_by}${rest.length > 0 ? `; also ${rest.join(', ')}` : ''}`;
571
+ }
572
+ const lines = [`Ceremony: ${v.tier} (${mode})`];
573
+ for (const w of v.warnings || [])
574
+ lines.push(`Warning: ${sanitizeEvidence(w)}`);
575
+ lines.push('', '| Question | Answer | Evidence |', '| --- | --- | --- |');
576
+ for (const q of QUESTIONS) {
577
+ const a = v.answers[q];
578
+ lines.push(`| ${QUESTION_LABELS[q]} | ${a.value ? 'yes' : 'no'} | ${cell(a.evidence)} |`);
579
+ }
580
+ lines.push('', '| Gate | Decision | Reason |', '| --- | --- | --- |');
581
+ for (const key of exports.CEREMONY_GATES) {
582
+ const g = v.gates[key];
583
+ if (!g)
584
+ continue;
585
+ lines.push(`| ${GATE_LABELS[key] ?? key} | ${decisionText(g)} | ${cell(g.reason)} |`);
586
+ }
587
+ return `${lines.join('\n')}\n`;
588
+ }
589
+ // ─── CEREMONY.md text (D-11) ─────────────────────────────────────────────────
590
+ function gateFrontmatter(g) {
591
+ const out = { decision: g.decision };
592
+ if (g.depth !== undefined)
593
+ out.depth = sanitizeEvidence(g.depth);
594
+ if (g.rounds !== undefined)
595
+ out.rounds = g.rounds;
596
+ if (g.mode !== undefined)
597
+ out.mode = sanitizeEvidence(g.mode);
598
+ out.reason = sanitizeEvidence(g.reason);
599
+ return out;
600
+ }
601
+ /**
602
+ * The `{padded}-CEREMONY.md` text: the D-11 frontmatter in a fixed key order, then
603
+ * the table body. `forced_by` is written as the string `none` when null (the
604
+ * frontmatter helper drops null). No timestamp, so identical verdicts serialize
605
+ * byte-identically. `warnings` and `forced_by_all` live in the body only.
606
+ */
607
+ function serializeCeremony(v) {
608
+ const answers = {};
609
+ for (const q of QUESTIONS) {
610
+ answers[q] = { value: v.answers[q].value, evidence: sanitizeEvidence(v.answers[q].evidence) };
611
+ }
612
+ const gates = {};
613
+ for (const key of exports.CEREMONY_GATES) {
614
+ const g = v.gates[key];
615
+ if (g)
616
+ gates[key] = gateFrontmatter(g);
617
+ }
618
+ const front = {
619
+ schema_version: String(exports.CEREMONY_SCHEMA_VERSION),
620
+ phase: sanitizeEvidence(v.phase),
621
+ mode: v.mode,
622
+ forced_by: v.forced_by ?? 'none',
623
+ tier: v.tier,
624
+ answers,
625
+ gates,
626
+ };
627
+ return spliceFrontmatter(renderCeremonyTable(v), front);
628
+ }
629
+ function isRecord(x) {
630
+ return typeof x === 'object' && x !== null && !Array.isArray(x);
631
+ }
632
+ function nonEmpty(x) {
633
+ return typeof x === 'string' && x.length > 0;
634
+ }
635
+ /** The text after the CEREMONY.md frontmatter: the table the CLI printed. */
636
+ function bodyOf(text) {
637
+ const m = text.match(/^---\r?\n[\s\S]+?\r?\n---\r?\n(?:\r?\n)?/);
638
+ return m ? text.slice(m[0].length) : text;
639
+ }
640
+ const MAX_RECORDED_WARNINGS = 10;
641
+ /**
642
+ * WR-01: the body-only parts of a verdict, read back from the table header so the
643
+ * table can be re-rendered from the frontmatter: the other forcing sources named
644
+ * after `forced by`, and the `Warning:` lines, each re-sanitized. Anything that
645
+ * does not fit is dropped; neither can open or close a gate.
646
+ */
647
+ function bodyExtras(text, forcedBy) {
648
+ const lines = bodyOf(text).split(/\r?\n/);
649
+ const forcedAll = forcedBy ? [forcedBy] : [];
650
+ const also = forcedBy ? /^Ceremony: \S+ \(full, forced by \S+; also ([a-z, ]+)\)$/.exec(lines[0] ?? '') : null;
651
+ if (also) {
652
+ for (const s of also[1].split(', ')) {
653
+ if (FORCED_BY.includes(s) && !forcedAll.includes(s))
654
+ forcedAll.push(s);
655
+ }
656
+ }
657
+ const warnings = [];
658
+ for (let k = 1; k < lines.length && lines[k] !== '' && warnings.length < MAX_RECORDED_WARNINGS; k++) {
659
+ const w = /^Warning: (.+)$/.exec(lines[k]);
660
+ if (w)
661
+ warnings.push(sanitizeEvidence(w[1]));
662
+ }
663
+ return { forcedAll, warnings };
664
+ }
665
+ /**
666
+ * WR-01: true when every row follows from the verdict's own answers, tier and
667
+ * mode, as `classify` would have written it. A skip is allowed only where the
668
+ * rules allow one, and the light-tier depths only at a proportional light tier.
669
+ * A `flag --skip-research` or `flag --skip-verify` reason is never accepted:
670
+ * WR-06 keeps those run-only flags out of every recorded verdict, so a file
671
+ * carrying one was edited by hand (WR-01, iteration 2).
672
+ */
673
+ function verdictConsistent(mode, tier, forcedBy, answers, gates) {
674
+ const trust = answers.trust_boundary.value;
675
+ const codebase = answers.codebase_to_match.value;
676
+ const single = answers.serial_single_concern.value;
677
+ if (tier !== (single && !trust ? 'light' : !single && trust ? 'heavy' : 'standard'))
678
+ return false;
679
+ const prop = mode === 'proportional';
680
+ if (prop && forcedBy !== null)
681
+ return false;
682
+ const light = prop && tier === 'light';
683
+ const skipAllowed = new Map([
684
+ ['research', prop && !codebase],
685
+ ['pattern_mapper', prop && !codebase],
686
+ ['threat_model', prop && !trust],
687
+ ['security_review', prop && !trust],
688
+ ['plan_check', false],
689
+ ['nyquist', light],
690
+ ['ui_review', light],
691
+ ]);
692
+ for (const [key, g] of Object.entries(gates)) {
693
+ if (g.decision === 'skip' && skipAllowed.get(key) !== true)
694
+ return false;
695
+ }
696
+ const v = gates.verifier;
697
+ if (v?.depth === 'light' && !(light && !trust))
698
+ return false;
699
+ const pc = gates.plan_check;
700
+ if ((pc?.rounds === 1 || pc?.mode === 'blockers-only') && !light)
701
+ return false;
702
+ const cr = gates.code_review;
703
+ if (cr?.depth === 'quick' && !light && cr.reason !== 'configured depth')
704
+ return false;
705
+ return true;
706
+ }
707
+ /**
708
+ * The verdict in a CEREMONY.md text, or null on any contract violation (T-37-05),
709
+ * including a verdict whose rows do not follow from its own answers (WR-01).
710
+ */
711
+ function parseCeremony(text) {
712
+ if (typeof text !== 'string' || text === '')
713
+ return null;
714
+ let fm;
715
+ try {
716
+ fm = extractFrontmatter(text);
717
+ }
718
+ catch {
719
+ return null;
720
+ }
721
+ if (fm.schema_version !== String(exports.CEREMONY_SCHEMA_VERSION))
722
+ return null;
723
+ if (!nonEmpty(fm.phase))
724
+ return null;
725
+ if (typeof fm.mode !== 'string' || !MODES.includes(fm.mode))
726
+ return null;
727
+ if (typeof fm.tier !== 'string' || !TIERS.includes(fm.tier))
728
+ return null;
729
+ let forcedBy = null;
730
+ if (fm.forced_by !== undefined && fm.forced_by !== 'none') {
731
+ if (typeof fm.forced_by !== 'string' || !FORCED_BY.includes(fm.forced_by))
732
+ return null;
733
+ forcedBy = fm.forced_by;
734
+ }
735
+ if (!isRecord(fm.answers) || !isRecord(fm.gates))
736
+ return null;
737
+ const answers = {};
738
+ for (const q of QUESTIONS) {
739
+ const a = fm.answers[q];
740
+ if (!isRecord(a))
741
+ return null;
742
+ if (a.value !== 'true' && a.value !== 'false')
743
+ return null;
744
+ if (!nonEmpty(a.evidence))
745
+ return null;
746
+ answers[q] = { value: a.value === 'true', evidence: a.evidence };
747
+ }
748
+ const gates = {};
749
+ for (const key of exports.CEREMONY_GATES) {
750
+ const g = fm.gates[key];
751
+ if (!isRecord(g))
752
+ return null;
753
+ if (typeof g.decision !== 'string' || !DECISIONS.includes(g.decision))
754
+ return null;
755
+ if (!nonEmpty(g.reason))
756
+ return null;
757
+ const parsed = { decision: g.decision, reason: g.reason };
758
+ const ordered = { decision: parsed.decision };
759
+ if (g.depth !== undefined) {
760
+ if (!nonEmpty(g.depth))
761
+ return null;
762
+ ordered.depth = g.depth;
763
+ }
764
+ if (g.rounds !== undefined) {
765
+ const n = Number(g.rounds);
766
+ if (!Number.isInteger(n) || n < 1)
767
+ return null;
768
+ ordered.rounds = n;
769
+ }
770
+ if (g.mode !== undefined) {
771
+ if (!nonEmpty(g.mode))
772
+ return null;
773
+ ordered.mode = g.mode;
774
+ }
775
+ ordered.reason = parsed.reason;
776
+ gates[key] = ordered;
777
+ }
778
+ if (!verdictConsistent(fm.mode, fm.tier, forcedBy, answers, gates))
779
+ return null;
780
+ const extras = bodyExtras(text, forcedBy);
781
+ return {
782
+ schema_version: 1,
783
+ phase: fm.phase,
784
+ mode: fm.mode,
785
+ forced_by: forcedBy,
786
+ forced_by_all: extras.forcedAll,
787
+ tier: fm.tier,
788
+ answers,
789
+ gates,
790
+ warnings: extras.warnings,
791
+ };
792
+ }
793
+ // ─── Derivations the callers fold in (D-15) ──────────────────────────────────
794
+ /**
795
+ * The flat keys `init` returns and workflows read. Anything unexpected in the
796
+ * verdict reads as the full value, never the lighter one.
797
+ */
798
+ function deriveCeremonyKeys(verdict, configuredReviewDepth) {
799
+ const prop = verdict.mode === 'proportional';
800
+ const pc = verdict.gates?.plan_check;
801
+ const oneRound = prop && pc?.decision === 'run' && pc.rounds === 1;
802
+ const lightVerifier = prop && verdict.gates?.verifier?.decision === 'run' && verdict.gates.verifier.depth === 'light';
803
+ const cr = verdict.gates?.code_review;
804
+ // WR-04 (iteration 2): the verdict lowers review depth only at the light tier.
805
+ // At standard or heavy the recorded depth is the config of plan time, so the
806
+ // live config decides; a stale verdict never lowers review below it.
807
+ const quick = prop && verdict.tier === 'light' && cr?.decision === 'run' && cr.depth === 'quick';
808
+ return {
809
+ ceremony_tier: verdict.tier,
810
+ ceremony_mode: verdict.mode,
811
+ plan_check_rounds: oneRound ? 1 : 3,
812
+ plan_check_mode: oneRound && pc?.mode === 'blockers-only' ? 'blockers-only' : 'standard',
813
+ verifier_depth: lightVerifier ? 'light' : 'full',
814
+ code_review_depth: quick ? 'quick' : normalizeDepth(configuredReviewDepth),
815
+ };
816
+ }
817
+ /**
818
+ * True unless the verdict is a proportional one that skips this gate. A null
819
+ * verdict, full mode, an unknown gate and an `off` row (config, not ceremony)
820
+ * all read as open (D-12, D-15).
821
+ */
822
+ function ceremonyGateOpen(verdict, gate) {
823
+ if (!verdict || verdict.mode !== 'proportional')
824
+ return true;
825
+ const g = verdict.gates?.[gate];
826
+ if (!g)
827
+ return true;
828
+ return g.decision !== 'skip';
829
+ }
830
+ // ─── I/O shell (D-01, D-10, D-11, D-12, D-15) ────────────────────────────────
831
+ //
832
+ // Everything below reads the repository through its existing owners and serves
833
+ // the `ceremony classify|get` CLI. Nothing above this marker reads a file, git,
834
+ // the environment or a clock. The shell reads no client-identifying input.
835
+ /** D-03 / research A7: file extensions that count as source. */
836
+ exports.SOURCE_EXTENSIONS = [
837
+ 'js', 'cjs', 'mjs', 'jsx', 'ts', 'cts', 'mts', 'tsx', 'py', 'rb', 'go', 'rs', 'java', 'kt',
838
+ 'cs', 'php', 'swift', 'c', 'h', 'cpp', 'hpp', 'vue', 'svelte', 'sh',
839
+ ];
840
+ /** D-03: a path with one of these segments is vendored, generated or planning state. */
841
+ exports.EXCLUDED_PATH_SEGMENTS = ['.planning', 'node_modules', 'vendor', 'dist', 'build'];
842
+ const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/;
843
+ const SUCCESS_CRITERIA_HEADER_RE = /^\*\*Success Criteria:?\*\*[^\n]*:/i;
844
+ /** A list item: a number with `.` or `)`, or a `-`, `*` or `+` bullet, then whitespace. */
845
+ const LIST_ITEM_RE = /^[ \t]*(?:\d+[.)]|[-*+])[ \t]+(\S[^\n]*)$/;
846
+ const PLANS_LINE_RE = /^\*\*Plans:?\*\*/m;
847
+ /**
848
+ * CR-03: a `**Ceremony:** value`, `**Ceremony**: value` or `**Ceremony: value**`
849
+ * line, optionally a list item; group 1 is the value with any bold markers left.
850
+ */
851
+ const CEREMONY_LINE_RE = /^[ \t]*(?:[-*+][ \t]+)?\*\*Ceremony(?::\*\*|\*\*[ \t]*:|:)[ \t]*([^\n]*)$/gim;
852
+ const DEFERRED_RE = /<deferred>[\s\S]*?<\/deferred>/gi;
853
+ const CHECKBOX_PHASE_RE = /^\s*-\s*\[[xX]\]\s*(?:\*\*)?Phase\s+([0-9]+(?:\.[0-9]+)*)/gm;
854
+ const GIT_TIMEOUT_MS = 30000;
855
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
856
+ /**
857
+ * The only config read. `workflow.ceremony` and every capability-owned gate key go
858
+ * through `resolveConfigKey` (loadConfig drops keys it does not know); the checker
859
+ * and verifier toggles come from loadConfig's flat names.
860
+ */
861
+ function resolveGateConfig(cwd) {
862
+ const config = loadConfig(cwd);
863
+ const registry = _require('./capability-registry.cjs');
864
+ const read = (key) => resolveConfigKey(key, { config, cwd, registry });
865
+ const flag = (key) => {
866
+ const r = read(key);
867
+ return r.found ? Boolean(r.value) : false;
868
+ };
869
+ const depth = read('workflow.code_review_depth');
870
+ const mode = read('workflow.ceremony');
871
+ return {
872
+ research: flag('workflow.research'),
873
+ pattern_mapper: flag('workflow.pattern_mapper'),
874
+ plan_check: Boolean(config['plan_checker']),
875
+ verifier: Boolean(config['verifier']),
876
+ code_review: flag('workflow.code_review'),
877
+ nyquist: flag('workflow.nyquist_validation'),
878
+ security: flag('workflow.security_enforcement'),
879
+ ui_phase: flag('workflow.ui_phase'),
880
+ ui_safety_gate: flag('workflow.ui_safety_gate'),
881
+ ui_review: flag('workflow.ui_review'),
882
+ vault: flag('vault.enabled'),
883
+ code_review_depth: depth.found && typeof depth.value === 'string' ? depth.value : 'standard',
884
+ ceremonyMode: mode.found ? mode.value : undefined,
885
+ };
886
+ }
887
+ /** `1`, `01` and `1.0` compare equal; anything else compares by upper-cased text. */
888
+ function normalizePhaseNumber(token) {
889
+ const parts = String(token).split('.');
890
+ if (parts.every((p) => /^\d+$/.test(p)))
891
+ return parts.map((p) => String(parseInt(p, 10))).join('.');
892
+ return String(token).toUpperCase();
893
+ }
894
+ function readTextOrEmpty(file) {
895
+ try {
896
+ return node_fs_1.default.readFileSync(file, 'utf8');
897
+ }
898
+ catch {
899
+ return '';
900
+ }
901
+ }
902
+ function isSummaryName(name) {
903
+ return name.endsWith('-SUMMARY.md') || name === 'SUMMARY.md';
904
+ }
905
+ function listNames(dir) {
906
+ try {
907
+ return node_fs_1.default.readdirSync(dir);
908
+ }
909
+ catch {
910
+ return [];
911
+ }
912
+ }
913
+ /** Tracked source files outside planning and vendored paths; null when git cannot answer. */
914
+ function countTrackedSourceFiles(cwd) {
915
+ let out;
916
+ try {
917
+ out = (0, node_child_process_1.execFileSync)('git', ['ls-files', '-z'], {
918
+ cwd,
919
+ encoding: 'utf8',
920
+ maxBuffer: GIT_MAX_BUFFER,
921
+ timeout: GIT_TIMEOUT_MS,
922
+ windowsHide: true,
923
+ stdio: ['ignore', 'pipe', 'ignore'],
924
+ });
925
+ }
926
+ catch {
927
+ return null;
928
+ }
929
+ let count = 0;
930
+ for (const file of out.split('\0')) {
931
+ if (file === '')
932
+ continue;
933
+ const segments = file.split('/');
934
+ const name = segments[segments.length - 1];
935
+ if (segments.some((s) => exports.EXCLUDED_PATH_SEGMENTS.includes(s)))
936
+ continue;
937
+ if (name.includes('.min.'))
938
+ continue;
939
+ const dot = name.lastIndexOf('.');
940
+ if (dot <= 0)
941
+ continue;
942
+ if (exports.SOURCE_EXTENSIONS.includes(name.slice(dot + 1).toLowerCase()))
943
+ count++;
944
+ }
945
+ return count;
946
+ }
947
+ /** null when detection throws (CR-02): the classifier then cannot rule out UI + backend. */
948
+ function frontendOf(cwd, phase) {
949
+ try {
950
+ const router = _require('./check-command-router.cjs');
951
+ return Boolean(router.computeUiPlanGate(cwd, phase).frontend);
952
+ }
953
+ catch {
954
+ return null;
955
+ }
956
+ }
957
+ /**
958
+ * The list items under a header line: blank lines and indented continuation
959
+ * lines are passed over, the first other line ends the list.
960
+ */
961
+ function listItemsAfter(lines, start) {
962
+ const items = [];
963
+ for (let k = start; k < lines.length; k++) {
964
+ const line = lines[k];
965
+ if (line.trim() === '')
966
+ continue;
967
+ const m = line.match(LIST_ITEM_RE);
968
+ if (m)
969
+ items.push(m[1]);
970
+ else if (!/^[ \t]/.test(line))
971
+ break;
972
+ }
973
+ return items;
974
+ }
975
+ /**
976
+ * CR-02 (iteration 2): the separators between requirement IDs: a comma,
977
+ * semicolon, slash, ampersand or the word "and" (not inside an ID like AND-01).
978
+ * The pieces are trimmed by the caller, which is why the pattern carries no
979
+ * whitespace quantifier (37-W-1: the leading one was quadratic on a long
980
+ * whitespace run). Whitespace is never part of a separator, so the set of
981
+ * separator matches is the same as with the quantifiers.
982
+ */
983
+ const REQ_ID_SEPARATOR_RE = /(?:[,;/&]|(?<![\w-])and(?![\w-]))/i;
984
+ /**
985
+ * The characters stripped from the end of a token before the ID-shape test. An
986
+ * index loop strips them: an end-anchored `+` class was quadratic on a long
987
+ * punctuation run (37-W-1).
988
+ */
989
+ const REQ_ID_TRAILING_PUNCT = ':;.,!?\'"';
990
+ /**
991
+ * WR-02 (iteration 3): a requirement ID: a letter, letters or digits, a hyphen,
992
+ * digits (SLUG-01, VSEC-02), kept in the case it is written in. Nothing else on
993
+ * the line is an ID, so a placeholder (`TBD`, `None`, `N/A`) and prose map none.
994
+ */
995
+ const REQ_ID_SHAPE_RE = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
996
+ /**
997
+ * CR-03 (iteration 3): a range of one prefix ("DATA-01 – DATA-03", "-", "—",
998
+ * "..", "to", "through", "thru"); group 1 is the prefix, 2 and 3 the endpoints.
999
+ */
1000
+ const REQ_ID_RANGE_RE = /\b([A-Za-z][A-Za-z0-9]*)-(\d+)\s*(?:–|—|-|\.\.|to|through|thru)\s*\1-(\d+)\b/gi;
1001
+ /** The most IDs one range expands into; a wider or reversed range is an input gap. */
1002
+ const MAX_RANGE_IDS = 50;
1003
+ /**
1004
+ * The requirement IDs in one text: every ID-shaped token, in order. A range
1005
+ * expands into every ID in it; one past MAX_RANGE_IDS (or reversed) stays a
1006
+ * single `DATA-01..DATA-99` entry that no REQUIREMENTS.md line names, so it is
1007
+ * reported as unread input. Prose words are dropped, never read as IDs; a line
1008
+ * with no ID at all reads as zero IDs, which is not single-concern.
1009
+ */
1010
+ function reqIdsIn(text) {
1011
+ const ids = [];
1012
+ // `N/A` is a placeholder, not the IDs `N` and `A`: drop it before the slash splits it.
1013
+ let cleaned = text.replace(/[[\]*`]/g, '').replace(/(?<![\w-])N\/A(?![\w-])/gi, ' ');
1014
+ cleaned = cleaned.replace(REQ_ID_RANGE_RE, (_m, p, a, b) => {
1015
+ const lo = parseInt(a, 10);
1016
+ const hi = parseInt(b, 10);
1017
+ if (hi < lo || hi - lo + 1 > MAX_RANGE_IDS) {
1018
+ ids.push(`${p}-${a}..${p}-${b}`);
1019
+ return ' , ';
1020
+ }
1021
+ return Array.from({ length: hi - lo + 1 }, (_v, k) => `${p}-${String(lo + k).padStart(a.length, '0')}`).join(', ');
1022
+ });
1023
+ for (const piece of cleaned.split(REQ_ID_SEPARATOR_RE)) {
1024
+ for (const token of piece.trim().split(/[\s()]+/)) {
1025
+ let end = token.length;
1026
+ while (end > 0 && REQ_ID_TRAILING_PUNCT.includes(token[end - 1]))
1027
+ end--;
1028
+ const t = token.slice(0, end);
1029
+ if (REQ_ID_SHAPE_RE.test(t))
1030
+ ids.push(t);
1031
+ }
1032
+ }
1033
+ return [...new Set(ids)];
1034
+ }
1035
+ /** CR-02: the IDs on the `**Requirements**` line, or in the list under it. */
1036
+ function parseReqIds(lines) {
1037
+ const at = lines.findIndex((l) => REQUIREMENTS_HEADER_RE.test(l));
1038
+ if (at < 0)
1039
+ return [];
1040
+ const inline = lines[at].match(REQUIREMENTS_HEADER_RE)[1].trim();
1041
+ if (inline !== '')
1042
+ return reqIdsIn(inline);
1043
+ return listItemsAfter(lines, at + 1).flatMap((item) => reqIdsIn(item.replace(/^\[[ xX]\][ \t]*/, '').split(':')[0]));
1044
+ }
1045
+ /** CR-02: numbered or bulleted items under `**Success Criteria**`. */
1046
+ function countSuccessCriteria(lines) {
1047
+ const at = lines.findIndex((l) => SUCCESS_CRITERIA_HEADER_RE.test(l));
1048
+ return at < 0 ? 0 : listItemsAfter(lines, at + 1).length;
1049
+ }
1050
+ /**
1051
+ * CR-03: the `**Ceremony**` lines anywhere in the phase section (Plans list
1052
+ * included). `forcesFull` when any reads `full`; else `ignored` is the first value.
1053
+ */
1054
+ function readCeremonyMarker(section) {
1055
+ let ignored = null;
1056
+ for (const m of section.matchAll(CEREMONY_LINE_RE)) {
1057
+ const value = m[1].replace(/[*`_]/g, '').trim();
1058
+ if (/^full\b/i.test(value))
1059
+ return { forcesFull: true, ignored: null };
1060
+ if (ignored === null)
1061
+ ignored = value;
1062
+ }
1063
+ return { forcesFull: false, ignored };
1064
+ }
1065
+ /** The REQUIREMENTS.md lines naming any of the IDs, and the IDs no line names. */
1066
+ function requirementLinesFor(text, reqIds) {
1067
+ const res = reqIds.map((id) => ({ id, re: new RegExp(`(?<![A-Za-z0-9_-])${escapeRegex(id)}(?![A-Za-z0-9_])`) }));
1068
+ const found = new Set();
1069
+ const lines = [];
1070
+ for (const line of text.split(/\r?\n/)) {
1071
+ let hit = false;
1072
+ for (const { id, re } of res) {
1073
+ if (re.test(line)) {
1074
+ found.add(id);
1075
+ hit = true;
1076
+ }
1077
+ }
1078
+ if (hit)
1079
+ lines.push(line);
1080
+ }
1081
+ return { lines, withoutText: reqIds.filter((id) => !found.has(id)) };
1082
+ }
1083
+ /** Distinct completed phase numbers other than this one (research A8: over-inclusion is safe). */
1084
+ function countCompletedOtherPhases(cwd, thisToken) {
1085
+ const self = normalizePhaseNumber(thisToken);
1086
+ const paths = planningPaths(cwd);
1087
+ const done = new Set();
1088
+ const roadmapText = readTextOrEmpty(paths.roadmap);
1089
+ for (const m of roadmapText.matchAll(CHECKBOX_PHASE_RE)) {
1090
+ const n = normalizePhaseNumber(m[1]);
1091
+ if (n !== self)
1092
+ done.add(n);
1093
+ }
1094
+ for (const dirName of listNames(paths.phases)) {
1095
+ const n = normalizePhaseNumber(extractPhaseToken(dirName) || dirName);
1096
+ if (n === self || done.has(n))
1097
+ continue;
1098
+ if (listNames(node_path_1.default.join(paths.phases, dirName)).some(isSummaryName))
1099
+ done.add(n);
1100
+ }
1101
+ return done.size;
1102
+ }
1103
+ function countArchivedMilestones(cwd) {
1104
+ const paths = planningPaths(cwd);
1105
+ let count = listNames(paths.milestonesArchive).filter((n) => n.endsWith('-ROADMAP.md')).length;
1106
+ const archiveDir = node_path_1.default.join(paths.planning, 'archive');
1107
+ for (const name of listNames(archiveDir)) {
1108
+ try {
1109
+ if (node_fs_1.default.statSync(node_path_1.default.join(archiveDir, name)).isDirectory())
1110
+ count++;
1111
+ }
1112
+ catch {
1113
+ /* unreadable entry: not counted */
1114
+ }
1115
+ }
1116
+ return count;
1117
+ }
1118
+ /** The phase directory (absolute) and its token, or null for an unknown phase. */
1119
+ function locatePhase(cwd, phase) {
1120
+ const match = resolvePhaseForRead(cwd, phase);
1121
+ if (!match || typeof match['directory'] !== 'string' || match['directory'] === '')
1122
+ return null;
1123
+ const rel = match['directory'];
1124
+ const dirName = node_path_1.default.posix.basename(rel.replace(/\\/g, '/'));
1125
+ return { dir: node_path_1.default.join(cwd, rel), rel, token: extractPhaseToken(dirName) || dirName };
1126
+ }
1127
+ /** Every classifier input, each read through its existing owner (D-01, D-03, D-04). */
1128
+ function gatherInputs(cwd, phase, flags, resolved) {
1129
+ const gate = resolved ?? resolveGateConfig(cwd);
1130
+ const located = locatePhase(cwd, phase);
1131
+ let section = '';
1132
+ try {
1133
+ section = getRoadmapPhaseWithFallback(cwd, phase) ?? '';
1134
+ }
1135
+ catch {
1136
+ section = '';
1137
+ }
1138
+ // CR-03: the full-ceremony marker counts anywhere in the phase section.
1139
+ const marker = readCeremonyMarker(section);
1140
+ // Pitfall 5: the `**Plans**` list holds titles the planner chose; never scan it.
1141
+ const plansAt = section.search(PLANS_LINE_RE);
1142
+ if (plansAt >= 0)
1143
+ section = section.slice(0, plansAt);
1144
+ const sectionLines = section.split(/\r?\n/);
1145
+ const reqIds = parseReqIds(sectionLines);
1146
+ const successCriteria = countSuccessCriteria(sectionLines);
1147
+ const requirements = requirementLinesFor(readTextOrEmpty(planningPaths(cwd).requirements), reqIds);
1148
+ let contextText = '';
1149
+ if (located) {
1150
+ const names = listNames(located.dir).filter((n) => n.endsWith('-CONTEXT.md') || n === 'CONTEXT.md').sort();
1151
+ contextText = names.map((n) => readTextOrEmpty(node_path_1.default.join(located.dir, n))).join('\n').replace(DEFERRED_RE, '');
1152
+ }
1153
+ return {
1154
+ phase: located ? located.token : String(phase),
1155
+ roadmapText: section,
1156
+ requirementText: requirements.lines.join('\n'),
1157
+ contextText,
1158
+ reqIds,
1159
+ successCriteria,
1160
+ frontend: frontendOf(cwd, phase),
1161
+ requirementIdsWithoutText: requirements.withoutText,
1162
+ trackedSourceFiles: countTrackedSourceFiles(cwd),
1163
+ completedOtherPhases: countCompletedOtherPhases(cwd, located ? located.token : String(phase)),
1164
+ archivedMilestones: countArchivedMilestones(cwd),
1165
+ configMode: gate.ceremonyMode,
1166
+ roadmapForcesFull: marker.forcesFull,
1167
+ roadmapCeremonyIgnored: marker.ignored,
1168
+ flags,
1169
+ gateConfig: gate,
1170
+ };
1171
+ }
1172
+ function readRecorded(cwd, phase) {
1173
+ const located = locatePhase(cwd, phase);
1174
+ if (!located)
1175
+ return null;
1176
+ const rel = node_path_1.default.posix.join(located.rel.replace(/\\/g, '/'), `${located.token}-CEREMONY.md`);
1177
+ let text;
1178
+ try {
1179
+ text = node_fs_1.default.readFileSync(node_path_1.default.join(cwd, rel), 'utf8');
1180
+ }
1181
+ catch {
1182
+ return null;
1183
+ }
1184
+ const verdict = parseCeremony(text);
1185
+ return verdict ? { verdict, text, rel } : null;
1186
+ }
1187
+ /** The verdict recorded for a phase, or null when absent, unreadable or invalid (T-37-05). */
1188
+ function readRecordedVerdict(cwd, phase) {
1189
+ return readRecorded(cwd, phase)?.verdict ?? null;
1190
+ }
1191
+ /**
1192
+ * D-15: the recorded verdict, else the full default. Never computes a fresh verdict,
1193
+ * so a missing or broken file never lowers ceremony.
1194
+ */
1195
+ function effectiveVerdict(cwd, phase) {
1196
+ const recorded = readRecorded(cwd, phase);
1197
+ if (recorded)
1198
+ return { verdict: recorded.verdict, source: 'recorded', phase_found: true };
1199
+ return {
1200
+ verdict: defaultFullVerdict(String(phase), resolveGateConfig(cwd)),
1201
+ source: 'default',
1202
+ phase_found: locatePhase(cwd, phase) !== null,
1203
+ };
1204
+ }
1205
+ // ─── CLI router ──────────────────────────────────────────────────────────────
1206
+ const CLASSIFY_FLAGS = new Set(['--full', '--research', '--skip-research', '--skip-verify', '--write']);
1207
+ function parseCliArgs(argv, allowed) {
1208
+ const positionals = [];
1209
+ const flags = new Set();
1210
+ for (const arg of argv) {
1211
+ if (arg.startsWith('--')) {
1212
+ if (!allowed.has(arg))
1213
+ error(`Unknown ceremony flag: ${arg}`, ERROR_REASON.USAGE);
1214
+ flags.add(arg);
1215
+ }
1216
+ else {
1217
+ positionals.push(arg);
1218
+ }
1219
+ }
1220
+ // WR-02: a flag string that was not word-split (" --full") must fail loudly,
1221
+ // not be filed as an ignored positional while the verdict drops the flag.
1222
+ if (positionals.length > 1) {
1223
+ error(`Unexpected argument(s): ${positionals.slice(1).map((p) => JSON.stringify(p)).join(' ')}`, ERROR_REASON.USAGE);
1224
+ }
1225
+ return { positionals, flags };
1226
+ }
1227
+ function gateReport(g) {
1228
+ if (g.decision === 'run')
1229
+ return 'run';
1230
+ if (g.decision === 'off')
1231
+ return 'off (config)';
1232
+ return `skipped (ceremony: ${g.reason})`;
1233
+ }
1234
+ function buildResult(verdict, cfg, meta) {
1235
+ const gates = {};
1236
+ for (const key of exports.CEREMONY_GATES) {
1237
+ const g = verdict.gates[key];
1238
+ if (g)
1239
+ gates[key] = { ...g, report: gateReport(g) };
1240
+ }
1241
+ return {
1242
+ phase: verdict.phase,
1243
+ phase_found: meta.phase_found,
1244
+ source: meta.source,
1245
+ schema_version: verdict.schema_version,
1246
+ mode: verdict.mode,
1247
+ forced_by: verdict.forced_by,
1248
+ forced_by_all: verdict.forced_by_all,
1249
+ tier: verdict.tier,
1250
+ answers: verdict.answers,
1251
+ gates,
1252
+ warnings: verdict.warnings,
1253
+ file: meta.file,
1254
+ written: meta.written,
1255
+ reused: meta.reused,
1256
+ ...deriveCeremonyKeys(verdict, cfg.code_review_depth),
1257
+ };
1258
+ }
1259
+ function cmdClassify(cwd, argv, raw) {
1260
+ const { positionals, flags } = parseCliArgs(argv, CLASSIFY_FLAGS);
1261
+ const phase = positionals[0];
1262
+ if (phase === undefined || phase === '')
1263
+ error('Missing phase argument', ERROR_REASON.SDK_MISSING_ARG);
1264
+ const located = locatePhase(cwd, phase);
1265
+ if (!located)
1266
+ error(`Phase ${phase} not found`, ERROR_REASON.PHASE_NOT_FOUND);
1267
+ const cfg = resolveGateConfig(cwd);
1268
+ const ceremonyFlags = {
1269
+ full: flags.has('--full'),
1270
+ research: flags.has('--research'),
1271
+ skipResearch: flags.has('--skip-research'),
1272
+ skipVerify: flags.has('--skip-verify'),
1273
+ };
1274
+ // D-12: once the phase has a SUMMARY, the recorded verdict stands unless --full.
1275
+ const frozen = listNames(located.dir).some(isSummaryName) && !ceremonyFlags.full;
1276
+ const recorded = frozen ? readRecorded(cwd, phase) : null;
1277
+ let verdict;
1278
+ // WR-06: the verdict --write records. --skip-research and --skip-verify decide
1279
+ // this run only (plan-phase honours both itself), so they never enter the file.
1280
+ let toRecord;
1281
+ let source;
1282
+ let reused = false;
1283
+ if (recorded) {
1284
+ verdict = toRecord = recorded.verdict;
1285
+ source = 'recorded';
1286
+ reused = true;
1287
+ }
1288
+ else if (frozen) {
1289
+ verdict = toRecord = defaultFullVerdict(located.token, cfg);
1290
+ source = 'default';
1291
+ }
1292
+ else {
1293
+ const inputs = gatherInputs(cwd, phase, { ...ceremonyFlags, skipResearch: false, skipVerify: false }, cfg);
1294
+ toRecord = classify(inputs);
1295
+ verdict = ceremonyFlags.skipResearch || ceremonyFlags.skipVerify ? classify({ ...inputs, flags: ceremonyFlags }) : toRecord;
1296
+ source = 'computed';
1297
+ }
1298
+ const relFile = node_path_1.default.posix.join(located.rel.replace(/\\/g, '/'), `${located.token}-CEREMONY.md`);
1299
+ let written = false;
1300
+ if (flags.has('--write') && !reused) {
1301
+ const target = resolvePhaseForWrite(cwd, phase);
1302
+ if (!target.ok) {
1303
+ error(target.reason === 'archived'
1304
+ ? `Phase ${phase} is archived; CEREMONY.md is written to live phases only`
1305
+ : `Phase ${phase} directory not found`, ERROR_REASON.PHASE_NOT_FOUND);
1306
+ }
1307
+ node_fs_1.default.writeFileSync(node_path_1.default.join(cwd, relFile), serializeCeremony(toRecord));
1308
+ written = true;
1309
+ }
1310
+ // WR-01: always rendered from the parsed verdict, never the stored body.
1311
+ const text = renderCeremonyTable(verdict);
1312
+ output(buildResult(verdict, cfg, {
1313
+ source, phase_found: true, file: written || reused ? relFile : null, written, reused,
1314
+ }), raw, text);
1315
+ }
1316
+ function cmdGet(cwd, argv, raw) {
1317
+ const { positionals } = parseCliArgs(argv, new Set());
1318
+ const phase = positionals[0];
1319
+ if (phase === undefined || phase === '')
1320
+ error('Missing phase argument', ERROR_REASON.SDK_MISSING_ARG);
1321
+ const recorded = readRecorded(cwd, phase);
1322
+ const eff = effectiveVerdict(cwd, phase);
1323
+ const cfg = resolveGateConfig(cwd);
1324
+ const text = renderCeremonyTable(eff.verdict);
1325
+ output(buildResult(eff.verdict, cfg, {
1326
+ source: eff.source, phase_found: eff.phase_found, file: recorded ? recorded.rel : null, written: false, reused: false,
1327
+ }), raw, text);
1328
+ }
1329
+ function cmdCeremony(cwd, args, raw) {
1330
+ const sub = args[0];
1331
+ const rest = args.slice(1);
1332
+ switch (sub) {
1333
+ case 'classify':
1334
+ cmdClassify(cwd, rest, raw);
1335
+ return;
1336
+ case 'get':
1337
+ cmdGet(cwd, rest, raw);
1338
+ return;
1339
+ default:
1340
+ error(`Unknown ceremony subcommand: ${String(sub)}. Available: classify, get`, ERROR_REASON.SDK_UNKNOWN_COMMAND);
1341
+ }
1342
+ }