@nforma.ai/nforma 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (215) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +1024 -0
  3. package/agents/qgsd-codebase-mapper.md +764 -0
  4. package/agents/qgsd-debugger.md +1201 -0
  5. package/agents/qgsd-executor.md +472 -0
  6. package/agents/qgsd-integration-checker.md +443 -0
  7. package/agents/qgsd-phase-researcher.md +502 -0
  8. package/agents/qgsd-plan-checker.md +643 -0
  9. package/agents/qgsd-planner.md +1182 -0
  10. package/agents/qgsd-project-researcher.md +621 -0
  11. package/agents/qgsd-quorum-orchestrator.md +628 -0
  12. package/agents/qgsd-quorum-slot-worker.md +41 -0
  13. package/agents/qgsd-quorum-synthesizer.md +133 -0
  14. package/agents/qgsd-quorum-test-worker.md +37 -0
  15. package/agents/qgsd-quorum-worker.md +161 -0
  16. package/agents/qgsd-research-synthesizer.md +239 -0
  17. package/agents/qgsd-roadmapper.md +660 -0
  18. package/agents/qgsd-verifier.md +628 -0
  19. package/bin/accept-debug-invariant.cjs +165 -0
  20. package/bin/account-manager.cjs +719 -0
  21. package/bin/aggregate-requirements.cjs +466 -0
  22. package/bin/analyze-assumptions.cjs +757 -0
  23. package/bin/analyze-state-space.cjs +921 -0
  24. package/bin/attribute-trace-divergence.cjs +150 -0
  25. package/bin/auth-drivers/gh-cli.cjs +93 -0
  26. package/bin/auth-drivers/index.cjs +46 -0
  27. package/bin/auth-drivers/pool.cjs +67 -0
  28. package/bin/auth-drivers/simple.cjs +95 -0
  29. package/bin/autoClosePtoF.cjs +110 -0
  30. package/bin/blessed-terminal.cjs +350 -0
  31. package/bin/build-phase-index.cjs +472 -0
  32. package/bin/call-quorum-slot.cjs +541 -0
  33. package/bin/ccr-secure-config.cjs +99 -0
  34. package/bin/ccr-secure-start.cjs +83 -0
  35. package/bin/check-bundled-sdks.cjs +177 -0
  36. package/bin/check-coverage-guard.cjs +112 -0
  37. package/bin/check-liveness-fairness.cjs +95 -0
  38. package/bin/check-mcp-health.cjs +123 -0
  39. package/bin/check-provider-health.cjs +395 -0
  40. package/bin/check-results-exit.cjs +24 -0
  41. package/bin/check-spec-sync.cjs +360 -0
  42. package/bin/check-trace-redaction.cjs +271 -0
  43. package/bin/check-trace-schema-drift.cjs +99 -0
  44. package/bin/compareDrift.cjs +21 -0
  45. package/bin/conformance-schema.cjs +12 -0
  46. package/bin/count-scenarios.cjs +420 -0
  47. package/bin/debt-dedup.cjs +144 -0
  48. package/bin/debt-ledger.cjs +61 -0
  49. package/bin/debt-retention.cjs +76 -0
  50. package/bin/debt-state-machine.cjs +80 -0
  51. package/bin/detect-coverage-gaps.cjs +204 -0
  52. package/bin/detect-project-intent.cjs +362 -0
  53. package/bin/export-prism-constants.cjs +164 -0
  54. package/bin/extract-annotations.cjs +633 -0
  55. package/bin/extractFormalExpected.cjs +104 -0
  56. package/bin/fingerprint-drift.cjs +24 -0
  57. package/bin/fingerprint-issue.cjs +46 -0
  58. package/bin/formal-core.cjs +519 -0
  59. package/bin/formal-ref-linker.cjs +141 -0
  60. package/bin/formal-test-sync.cjs +788 -0
  61. package/bin/generate-formal-specs.cjs +588 -0
  62. package/bin/generate-petri-net.cjs +397 -0
  63. package/bin/generate-phase-spec.cjs +249 -0
  64. package/bin/generate-proposed-changes.cjs +194 -0
  65. package/bin/generate-tla-cfg.cjs +122 -0
  66. package/bin/generate-traceability-matrix.cjs +701 -0
  67. package/bin/generate-triage-bundle.cjs +300 -0
  68. package/bin/gh-account-rotate.cjs +34 -0
  69. package/bin/initialize-model-registry.cjs +105 -0
  70. package/bin/install-formal-tools.cjs +382 -0
  71. package/bin/install.js +2424 -0
  72. package/bin/isNumericThreshold.cjs +34 -0
  73. package/bin/issue-classifier.cjs +151 -0
  74. package/bin/levenshtein.cjs +74 -0
  75. package/bin/lint-formal-models.cjs +580 -0
  76. package/bin/load-baseline-requirements.cjs +275 -0
  77. package/bin/manage-agents-core.cjs +815 -0
  78. package/bin/migrate-formal-dir.cjs +172 -0
  79. package/bin/migrate-planning.cjs +206 -0
  80. package/bin/migrate-to-slots.cjs +255 -0
  81. package/bin/nForma.cjs +2726 -0
  82. package/bin/observe-config.cjs +353 -0
  83. package/bin/observe-debt-writer.cjs +140 -0
  84. package/bin/observe-handler-grafana.cjs +128 -0
  85. package/bin/observe-handler-internal.cjs +301 -0
  86. package/bin/observe-handler-logstash.cjs +153 -0
  87. package/bin/observe-handler-prometheus.cjs +185 -0
  88. package/bin/observe-handlers.cjs +436 -0
  89. package/bin/observe-registry.cjs +131 -0
  90. package/bin/observe-render.cjs +168 -0
  91. package/bin/planning-paths.cjs +167 -0
  92. package/bin/polyrepo.cjs +560 -0
  93. package/bin/prism-priority.cjs +153 -0
  94. package/bin/probe-quorum-slots.cjs +167 -0
  95. package/bin/promote-model.cjs +225 -0
  96. package/bin/propose-debug-invariants.cjs +165 -0
  97. package/bin/providers.json +392 -0
  98. package/bin/pty-proxy.py +129 -0
  99. package/bin/qgsd-solve.cjs +2477 -0
  100. package/bin/quorum-consensus-gate.cjs +238 -0
  101. package/bin/quorum-formal-context.cjs +183 -0
  102. package/bin/quorum-slot-dispatch.cjs +934 -0
  103. package/bin/read-policy.cjs +60 -0
  104. package/bin/requirement-map.cjs +63 -0
  105. package/bin/requirements-core.cjs +247 -0
  106. package/bin/resolve-cli.cjs +101 -0
  107. package/bin/review-mcp-logs.cjs +294 -0
  108. package/bin/run-account-manager-tlc.cjs +188 -0
  109. package/bin/run-account-pool-alloy.cjs +158 -0
  110. package/bin/run-alloy.cjs +153 -0
  111. package/bin/run-audit-alloy.cjs +187 -0
  112. package/bin/run-breaker-tlc.cjs +181 -0
  113. package/bin/run-formal-check.cjs +395 -0
  114. package/bin/run-formal-verify.cjs +701 -0
  115. package/bin/run-installer-alloy.cjs +188 -0
  116. package/bin/run-oauth-rotation-prism.cjs +132 -0
  117. package/bin/run-oscillation-tlc.cjs +202 -0
  118. package/bin/run-phase-tlc.cjs +228 -0
  119. package/bin/run-prism.cjs +446 -0
  120. package/bin/run-protocol-tlc.cjs +201 -0
  121. package/bin/run-quorum-composition-alloy.cjs +155 -0
  122. package/bin/run-sensitivity-sweep.cjs +231 -0
  123. package/bin/run-stop-hook-tlc.cjs +188 -0
  124. package/bin/run-tlc.cjs +467 -0
  125. package/bin/run-transcript-alloy.cjs +173 -0
  126. package/bin/run-uppaal.cjs +264 -0
  127. package/bin/secrets.cjs +134 -0
  128. package/bin/sensitivity-report.cjs +219 -0
  129. package/bin/sensitivity-sweep-feedback.cjs +194 -0
  130. package/bin/set-secret.cjs +29 -0
  131. package/bin/setup-telemetry-cron.sh +36 -0
  132. package/bin/sweepPtoF.cjs +63 -0
  133. package/bin/sync-baseline-requirements.cjs +290 -0
  134. package/bin/task-envelope.cjs +360 -0
  135. package/bin/telemetry-collector.cjs +229 -0
  136. package/bin/unified-mcp-server.mjs +735 -0
  137. package/bin/update-agents.cjs +369 -0
  138. package/bin/update-scoreboard.cjs +1134 -0
  139. package/bin/validate-debt-entry.cjs +207 -0
  140. package/bin/validate-invariant.cjs +419 -0
  141. package/bin/validate-memory.cjs +389 -0
  142. package/bin/validate-requirements-haiku.cjs +435 -0
  143. package/bin/validate-traces.cjs +438 -0
  144. package/bin/verify-formal-results.cjs +124 -0
  145. package/bin/verify-quorum-health.cjs +273 -0
  146. package/bin/write-check-result.cjs +106 -0
  147. package/bin/xstate-to-tla.cjs +483 -0
  148. package/bin/xstate-trace-walker.cjs +205 -0
  149. package/commands/qgsd/add-phase.md +43 -0
  150. package/commands/qgsd/add-requirement.md +24 -0
  151. package/commands/qgsd/add-todo.md +47 -0
  152. package/commands/qgsd/audit-milestone.md +37 -0
  153. package/commands/qgsd/check-todos.md +45 -0
  154. package/commands/qgsd/cleanup.md +18 -0
  155. package/commands/qgsd/close-formal-gaps.md +33 -0
  156. package/commands/qgsd/complete-milestone.md +136 -0
  157. package/commands/qgsd/debug.md +166 -0
  158. package/commands/qgsd/discuss-phase.md +83 -0
  159. package/commands/qgsd/execute-phase.md +117 -0
  160. package/commands/qgsd/fix-tests.md +27 -0
  161. package/commands/qgsd/formal-test-sync.md +32 -0
  162. package/commands/qgsd/health.md +22 -0
  163. package/commands/qgsd/help.md +22 -0
  164. package/commands/qgsd/insert-phase.md +32 -0
  165. package/commands/qgsd/join-discord.md +18 -0
  166. package/commands/qgsd/list-phase-assumptions.md +46 -0
  167. package/commands/qgsd/map-codebase.md +71 -0
  168. package/commands/qgsd/map-requirements.md +20 -0
  169. package/commands/qgsd/mcp-restart.md +176 -0
  170. package/commands/qgsd/mcp-set-model.md +134 -0
  171. package/commands/qgsd/mcp-setup.md +1371 -0
  172. package/commands/qgsd/mcp-status.md +274 -0
  173. package/commands/qgsd/mcp-update.md +238 -0
  174. package/commands/qgsd/new-milestone.md +44 -0
  175. package/commands/qgsd/new-project.md +42 -0
  176. package/commands/qgsd/observe.md +260 -0
  177. package/commands/qgsd/pause-work.md +38 -0
  178. package/commands/qgsd/plan-milestone-gaps.md +34 -0
  179. package/commands/qgsd/plan-phase.md +44 -0
  180. package/commands/qgsd/polyrepo.md +50 -0
  181. package/commands/qgsd/progress.md +24 -0
  182. package/commands/qgsd/queue.md +54 -0
  183. package/commands/qgsd/quick.md +133 -0
  184. package/commands/qgsd/quorum-test.md +275 -0
  185. package/commands/qgsd/quorum.md +707 -0
  186. package/commands/qgsd/reapply-patches.md +110 -0
  187. package/commands/qgsd/remove-phase.md +31 -0
  188. package/commands/qgsd/research-phase.md +189 -0
  189. package/commands/qgsd/resume-work.md +40 -0
  190. package/commands/qgsd/set-profile.md +34 -0
  191. package/commands/qgsd/settings.md +39 -0
  192. package/commands/qgsd/solve.md +565 -0
  193. package/commands/qgsd/sync-baselines.md +119 -0
  194. package/commands/qgsd/triage.md +233 -0
  195. package/commands/qgsd/update.md +37 -0
  196. package/commands/qgsd/verify-work.md +38 -0
  197. package/hooks/dist/config-loader.js +297 -0
  198. package/hooks/dist/conformance-schema.cjs +12 -0
  199. package/hooks/dist/gsd-context-monitor.js +64 -0
  200. package/hooks/dist/qgsd-check-update.js +62 -0
  201. package/hooks/dist/qgsd-circuit-breaker.js +682 -0
  202. package/hooks/dist/qgsd-precompact.js +156 -0
  203. package/hooks/dist/qgsd-prompt.js +653 -0
  204. package/hooks/dist/qgsd-session-start.js +122 -0
  205. package/hooks/dist/qgsd-slot-correlator.js +58 -0
  206. package/hooks/dist/qgsd-spec-regen.js +86 -0
  207. package/hooks/dist/qgsd-statusline.js +91 -0
  208. package/hooks/dist/qgsd-stop.js +553 -0
  209. package/hooks/dist/qgsd-token-collector.js +133 -0
  210. package/hooks/dist/unified-mcp-server.mjs +669 -0
  211. package/package.json +95 -0
  212. package/scripts/build-hooks.js +46 -0
  213. package/scripts/postinstall.js +48 -0
  214. package/scripts/secret-audit.sh +45 -0
  215. package/templates/qgsd.json +49 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Validation module for debt entries and ledger
3
+ * Implements runtime validation against debt.schema.json
4
+ */
5
+
6
+ const VALID_STATUSES = ['open', 'acknowledged', 'resolving', 'resolved'];
7
+ const VALID_ENVIRONMENTS = ['production', 'staging', 'development', 'test', 'local'];
8
+ const VALID_SOURCE_TYPES = ['github', 'sentry', 'sentry-feedback', 'prometheus', 'grafana', 'logstash', 'bash'];
9
+
10
+ /**
11
+ * Check if a string is valid ISO8601 date-time format
12
+ * @param {string} dateStr - String to validate
13
+ * @returns {boolean} true if valid ISO8601
14
+ */
15
+ function isValidISO8601(dateStr) {
16
+ if (typeof dateStr !== 'string') return false;
17
+ // Check basic ISO8601 format: YYYY-MM-DDTHH:MM:SSZ or with timezone offset
18
+ const iso8601Regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|[+-]\d{2}:\d{2})$/;
19
+ if (!iso8601Regex.test(dateStr)) return false;
20
+ // Also verify it's a valid date
21
+ const date = new Date(dateStr);
22
+ return !isNaN(date.getTime());
23
+ }
24
+
25
+ /**
26
+ * Validate a debt entry object
27
+ * @param {object} entry - Debt entry to validate
28
+ * @returns {boolean|string[]} true if valid, or array of error strings if invalid
29
+ */
30
+ function validateDebtEntry(entry) {
31
+ const errors = [];
32
+
33
+ // Type check: must be object
34
+ if (typeof entry !== 'object' || entry === null) {
35
+ return ['entry must be an object'];
36
+ }
37
+
38
+ // Check required fields: id
39
+ if (!entry.id || typeof entry.id !== 'string') {
40
+ errors.push('id required (string)');
41
+ } else {
42
+ // Validate id pattern: UUID v4 format
43
+ const idPattern = /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/;
44
+ if (!idPattern.test(entry.id)) {
45
+ errors.push('id must match UUID format');
46
+ }
47
+ }
48
+
49
+ // Check required fields: fingerprint
50
+ if (!entry.fingerprint || typeof entry.fingerprint !== 'string') {
51
+ errors.push('fingerprint required (string)');
52
+ } else {
53
+ // Validate fingerprint pattern: 16-64 hex chars
54
+ const fpPattern = /^[a-z0-9]{16,64}$/;
55
+ if (!fpPattern.test(entry.fingerprint)) {
56
+ errors.push('fingerprint must be 16-64 lowercase hex characters');
57
+ }
58
+ }
59
+
60
+ // Check required fields: title
61
+ if (!entry.title || typeof entry.title !== 'string' || entry.title.length < 1) {
62
+ errors.push('title required (non-empty string)');
63
+ } else if (entry.title.length > 256) {
64
+ errors.push('title must be <= 256 characters');
65
+ }
66
+
67
+ // Check required fields: occurrences
68
+ if (typeof entry.occurrences !== 'number' || entry.occurrences < 1) {
69
+ errors.push('occurrences required (integer >= 1)');
70
+ } else if (!Number.isInteger(entry.occurrences)) {
71
+ errors.push('occurrences must be an integer');
72
+ }
73
+
74
+ // Check required fields: first_seen
75
+ if (!entry.first_seen) {
76
+ errors.push('first_seen required (ISO8601 string)');
77
+ } else if (!isValidISO8601(entry.first_seen)) {
78
+ errors.push('first_seen must be ISO8601 format');
79
+ }
80
+
81
+ // Check required fields: last_seen
82
+ if (!entry.last_seen) {
83
+ errors.push('last_seen required (ISO8601 string)');
84
+ } else if (!isValidISO8601(entry.last_seen)) {
85
+ errors.push('last_seen must be ISO8601 format');
86
+ }
87
+
88
+ // Check timestamp ordering: last_seen >= first_seen
89
+ if (entry.first_seen && entry.last_seen && isValidISO8601(entry.first_seen) && isValidISO8601(entry.last_seen)) {
90
+ const firstDate = new Date(entry.first_seen);
91
+ const lastDate = new Date(entry.last_seen);
92
+ if (lastDate < firstDate) {
93
+ errors.push('last_seen must be >= first_seen');
94
+ }
95
+ }
96
+
97
+ // Check required fields: environments
98
+ if (!Array.isArray(entry.environments) || entry.environments.length === 0) {
99
+ errors.push('environments required (non-empty array)');
100
+ } else {
101
+ // Validate each environment value
102
+ for (const env of entry.environments) {
103
+ if (typeof env !== 'string' || !VALID_ENVIRONMENTS.includes(env)) {
104
+ errors.push(`invalid environment value: ${env} (must be one of: ${VALID_ENVIRONMENTS.join(', ')})`);
105
+ }
106
+ }
107
+ }
108
+
109
+ // Check required fields: status
110
+ if (!entry.status || typeof entry.status !== 'string') {
111
+ errors.push('status required (string)');
112
+ } else if (!VALID_STATUSES.includes(entry.status)) {
113
+ errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
114
+ }
115
+
116
+ // Check required fields: source_entries
117
+ if (!Array.isArray(entry.source_entries) || entry.source_entries.length === 0) {
118
+ errors.push('source_entries required (non-empty array)');
119
+ } else {
120
+ // Validate each source entry
121
+ for (let i = 0; i < entry.source_entries.length; i++) {
122
+ const se = entry.source_entries[i];
123
+ if (typeof se !== 'object' || se === null) {
124
+ errors.push(`source_entries[${i}] must be an object`);
125
+ continue;
126
+ }
127
+ if (!se.source_type || typeof se.source_type !== 'string' || !VALID_SOURCE_TYPES.includes(se.source_type)) {
128
+ errors.push(`source_entries[${i}].source_type required and must be one of: ${VALID_SOURCE_TYPES.join(', ')}`);
129
+ }
130
+ if (!se.source_id || typeof se.source_id !== 'string' || se.source_id.length === 0) {
131
+ errors.push(`source_entries[${i}].source_id required (non-empty string)`);
132
+ }
133
+ if (!se.observed_at || !isValidISO8601(se.observed_at)) {
134
+ errors.push(`source_entries[${i}].observed_at required (ISO8601 format)`);
135
+ }
136
+ }
137
+ }
138
+
139
+ // Check optional fields: formal_ref
140
+ if (entry.hasOwnProperty('formal_ref')) {
141
+ if (entry.formal_ref !== null && typeof entry.formal_ref !== 'string') {
142
+ errors.push('formal_ref must be string or null');
143
+ }
144
+ }
145
+
146
+ // Check optional fields: formal_ref_source
147
+ if (entry.hasOwnProperty('formal_ref_source')) {
148
+ const validSources = ['manual', 'auto-detect', 'spec-inferred'];
149
+ if (entry.formal_ref_source !== null &&
150
+ (typeof entry.formal_ref_source !== 'string' || !validSources.includes(entry.formal_ref_source))) {
151
+ errors.push('formal_ref_source must be "manual", "auto-detect", "spec-inferred", or null');
152
+ }
153
+ }
154
+
155
+ // Check for additional properties (additionalProperties: false)
156
+ const allowedProps = new Set([
157
+ 'id', 'fingerprint', 'title', 'occurrences', 'first_seen', 'last_seen',
158
+ 'environments', 'status', 'formal_ref', 'formal_ref_source', 'source_entries', 'resolved_at'
159
+ ]);
160
+ for (const key of Object.keys(entry)) {
161
+ if (!allowedProps.has(key)) {
162
+ errors.push(`additional property not allowed: ${key}`);
163
+ }
164
+ }
165
+
166
+ return errors.length === 0 ? true : errors;
167
+ }
168
+
169
+ /**
170
+ * Validate a debt ledger object
171
+ * @param {object} ledger - Debt ledger to validate
172
+ * @returns {boolean|string[]} true if valid, or array of error strings if invalid
173
+ */
174
+ function validateDebtLedger(ledger) {
175
+ const errors = [];
176
+
177
+ // Type check: must be object
178
+ if (typeof ledger !== 'object' || ledger === null) {
179
+ return ['ledger must be an object'];
180
+ }
181
+
182
+ // Check schema_version
183
+ if (ledger.schema_version !== '1') {
184
+ errors.push('schema_version must be "1"');
185
+ }
186
+
187
+ // Check debt_entries is array
188
+ if (!Array.isArray(ledger.debt_entries)) {
189
+ errors.push('debt_entries must be an array');
190
+ } else {
191
+ // Validate each entry
192
+ for (let i = 0; i < ledger.debt_entries.length; i++) {
193
+ const entryErrors = validateDebtEntry(ledger.debt_entries[i]);
194
+ if (entryErrors !== true) {
195
+ errors.push(`debt_entries[${i}]: ${entryErrors.join('; ')}`);
196
+ }
197
+ }
198
+ }
199
+
200
+ return errors.length === 0 ? true : errors;
201
+ }
202
+
203
+ module.exports = {
204
+ validateDebtEntry,
205
+ validateDebtLedger,
206
+ isValidISO8601
207
+ };
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * validate-invariant.cjs
6
+ *
7
+ * Two-layer invariant gate for requirements:
8
+ * Layer 1: Fast regex pass — rejects obvious non-invariants (<1ms)
9
+ * Layer 2: Heuristic borderline detection — flags cases for Haiku sub-agent review
10
+ *
11
+ * The script itself does NOT call Haiku. It outputs BORDERLINE verdicts for cases
12
+ * that need classification. The calling workflow (add-requirement.md, map-requirements.md)
13
+ * spawns a Haiku sub-agent via the Agent tool for those cases.
14
+ *
15
+ * Usage:
16
+ * node bin/validate-invariant.cjs --id=BLD-01 --text="hooks/dist/ rebuilt from current source"
17
+ * node bin/validate-invariant.cjs --batch --envelope=.planning/formal/requirements.json
18
+ * node bin/validate-invariant.cjs --batch --strict --envelope=.planning/formal/requirements.json
19
+ * node bin/validate-invariant.cjs --test
20
+ *
21
+ * Verdicts:
22
+ * INVARIANT — requirement has invariant language, passed regex
23
+ * NON_INVARIANT — caught by regex fast-pass
24
+ * BORDERLINE — needs Haiku sub-agent classification (no invariant language, past-tense heavy)
25
+ *
26
+ * Exit codes:
27
+ * 0 — validation complete (results printed)
28
+ * 1 — operational error
29
+ */
30
+
31
+ const fs = require('fs');
32
+ const path = require('path');
33
+
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ // Layer 1: Regex fast-pass
36
+ // ─────────────────────────────────────────────────────────────────────────────
37
+
38
+ const REGEX_RULES = [
39
+ {
40
+ name: 'past_achievement',
41
+ pattern: /\b(ACHIEVED|IMPLEMENTED|DELIVERED)\b/,
42
+ reason: 'Past achievement — archive as milestone finding',
43
+ },
44
+ {
45
+ name: 'release_task',
46
+ pattern: /\b(bumpe?d?\s+from\s+.*\s+to|git\s+tag|published\s+to\s+npm)\b/i,
47
+ reason: 'Release task — belongs in changelog',
48
+ },
49
+ {
50
+ name: 'migration_task',
51
+ pattern: /\b(git\s+mv|ported\s+to|archive[d]?\s+(in|to)|renamed.*preserved)\b/i,
52
+ reason: 'One-time migration — already completed',
53
+ },
54
+ {
55
+ name: 'changelog',
56
+ pattern: /\bCHANGELOG\b/,
57
+ reason: 'Changelog task — documentation, not invariant',
58
+ },
59
+ {
60
+ name: 'build_ci_gate',
61
+ pattern: /\b(npm\s+test\s+passes|rebuilt\s+from\s+current\s+source)\b/i,
62
+ reason: 'CI gate — acceptance criteria, not system property',
63
+ },
64
+ {
65
+ name: 'audit_finding',
66
+ pattern: /\b(no\s+drift\s+detected|audited\s+against|verified\s+by\s+spot.check)\b/i,
67
+ reason: 'Audit finding — a snapshot, not ongoing constraint',
68
+ },
69
+ {
70
+ name: 'past_improvement',
71
+ pattern: /\b(updated\s+to\s+(parallel|new|read|use)|hardened|validated\s+with.*test|gains\s+a\s+.*\s+field|improvement\s+areas\s+identified)\b/i,
72
+ reason: 'Past improvement — describes what was done, not what must hold',
73
+ },
74
+ {
75
+ name: 'past_completion',
76
+ pattern: /\b(added\s+to\s+`|removed\s+from\s+`|created\s+with|no\s+old\s+names\s+remain)\b/i,
77
+ reason: 'Past completion — describes a completed action, not ongoing constraint',
78
+ },
79
+ ];
80
+
81
+ /**
82
+ * Run regex fast-pass on a requirement's text.
83
+ * @param {string} text - Requirement text
84
+ * @returns {{ matched: boolean, reason?: string, rule?: string }}
85
+ */
86
+ function regexPass(text) {
87
+ // Strip backticks so patterns match content inside code spans
88
+ const stripped = text.replace(/`/g, '');
89
+ for (const rule of REGEX_RULES) {
90
+ if (rule.pattern.test(stripped)) {
91
+ return { matched: true, reason: rule.reason, rule: rule.name };
92
+ }
93
+ }
94
+ return { matched: false };
95
+ }
96
+
97
+ // ─────────────────────────────────────────────────────────────────────────────
98
+ // Borderline detection heuristic
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ const INVARIANT_LANGUAGE = /\b(must|shall|always|never|ensures?|prevents?|rejects?|blocks?|validates?|enforces?|requires?|guarantees?|maintains?)\b/i;
102
+
103
+ // Past-tense action verbs (exclude common participial adjectives used as modifiers)
104
+ const PAST_TENSE_ACTIONS = /\b(updated|created|added|removed|renamed|reviewed|identified|ported|archived|implemented|delivered|achieved|migrated|completed|hardened|validated|published|bumped|cleared|finalized)\b/gi;
105
+ const PRESENT_TENSE = /\b(is|are|has|have|does|do|can|will|may|should|activates?|writes?|reads?|runs?|tracks?|checks?|detects?|responds?)\b/gi;
106
+
107
+ /**
108
+ * Check if a requirement is borderline (needs Haiku sub-agent review).
109
+ * Returns true if it lacks invariant language and has majority past-tense action verbs.
110
+ * @param {string} text - Requirement text
111
+ * @returns {boolean}
112
+ */
113
+ function isBorderline(text) {
114
+ if (INVARIANT_LANGUAGE.test(text)) return false;
115
+
116
+ const pastMatches = (text.match(PAST_TENSE_ACTIONS) || []).length;
117
+ const presentMatches = (text.match(PRESENT_TENSE) || []).length;
118
+
119
+ // If majority past-tense action verbs or no strong present-tense verbs, it's borderline
120
+ return pastMatches > presentMatches || (pastMatches > 0 && presentMatches === 0);
121
+ }
122
+
123
+ /**
124
+ * Build the Haiku sub-agent prompt for a borderline requirement.
125
+ * Callers pass this to Agent(model: "haiku") in the workflow.
126
+ * @param {string} id - Requirement ID
127
+ * @param {string} text - Requirement text
128
+ * @returns {string}
129
+ */
130
+ function buildHaikuPrompt(id, text) {
131
+ return `You are a requirements invariant classifier.
132
+
133
+ A VALID requirement is an INVARIANT — a property that must hold at any point in time.
134
+ Test: "At any point, if you inspect the system, this property holds."
135
+
136
+ A NON-INVARIANT is a task, migration, past achievement, or process step.
137
+
138
+ Requirement: ${id}: ${text}
139
+
140
+ Classify as exactly one of:
141
+ - INVARIANT: <one-line reason>
142
+ - NON_INVARIANT: <one-line reason>`;
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ // Public API
147
+ // ─────────────────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Validate a single requirement (regex + borderline heuristic only).
151
+ * Does NOT call Haiku — returns BORDERLINE for cases needing sub-agent review.
152
+ * @param {{ id: string, text: string }} req
153
+ * @returns {{ verdict: string, reason?: string, layer?: string }}
154
+ */
155
+ function validateInvariant(req) {
156
+ // Layer 1: Regex fast-pass
157
+ const regexResult = regexPass(req.text);
158
+ if (regexResult.matched) {
159
+ return { verdict: 'NON_INVARIANT', reason: regexResult.reason, layer: 'regex' };
160
+ }
161
+
162
+ // Layer 2: Borderline detection → needs Haiku sub-agent
163
+ if (isBorderline(req.text)) {
164
+ return { verdict: 'BORDERLINE', reason: 'Lacks invariant language with past-tense verbs — needs Haiku sub-agent classification', layer: 'heuristic' };
165
+ }
166
+
167
+ // Has invariant language and passed regex → INVARIANT
168
+ return { verdict: 'INVARIANT' };
169
+ }
170
+
171
+ /**
172
+ * Validate a batch of requirements (regex + borderline heuristic only).
173
+ * @param {Array<{ id: string, text: string }>} requirements
174
+ * @returns {Array<{ id: string, verdict: string, reason?: string, layer?: string }>}
175
+ */
176
+ function validateInvariantBatch(requirements) {
177
+ return requirements.map(req => ({ id: req.id, ...validateInvariant(req) }));
178
+ }
179
+
180
+ // ─────────────────────────────────────────────────────────────────────────────
181
+ // Built-in test suite
182
+ // ─────────────────────────────────────────────────────────────────────────────
183
+
184
+ function runTests() {
185
+ const archivePath = path.join(__dirname, '..', '.planning', 'formal', 'archived-non-invariants.json');
186
+ if (!fs.existsSync(archivePath)) {
187
+ console.error('Test data not found: .planning/formal/archived-non-invariants.json');
188
+ process.exit(1);
189
+ }
190
+
191
+ const archived = JSON.parse(fs.readFileSync(archivePath, 'utf8'));
192
+ const nonInvariants = archived.entries || [];
193
+
194
+ console.log('=== Invariant Gate Test Suite ===\n');
195
+
196
+ // Test 1: All archived non-invariants should be caught by regex
197
+ console.log(`Test 1: Regex catches archived non-invariants (${nonInvariants.length} entries)`);
198
+ let regexCaught = 0;
199
+ let regexMissed = [];
200
+ for (const entry of nonInvariants) {
201
+ const result = regexPass(entry.text);
202
+ if (result.matched) {
203
+ regexCaught++;
204
+ } else {
205
+ regexMissed.push(entry.id);
206
+ }
207
+ }
208
+ console.log(` Caught: ${regexCaught}/${nonInvariants.length}`);
209
+ if (regexMissed.length > 0) {
210
+ console.log(` Missed (would go to Haiku sub-agent): ${regexMissed.join(', ')}`);
211
+ }
212
+
213
+ // Test 2: Known good invariants should pass
214
+ const goodInvariants = [
215
+ { id: 'ACT-01', text: '`.planning/current-activity.json` is written atomically at every major workflow state transition' },
216
+ { id: 'CONF-05', text: 'Config changes must trigger validation before being applied' },
217
+ { id: 'STATE-04', text: 'State transitions must be atomic — partial transitions are never persisted' },
218
+ { id: 'ENFC-01', text: 'Quorum enforcement must block plan execution when quorum is not met' },
219
+ { id: 'VERIFY-01', text: 'Verification always runs after phase execution completes' },
220
+ { id: 'AGENT-01', text: 'User can add a new claude-mcp-server instance (name, provider, model, key)' },
221
+ { id: 'BREAKER-01', text: 'Circuit breaker activates when 3+ alternating oscillation groups are detected' },
222
+ { id: 'HOOK-01', text: 'Hooks must never block session start on transient errors' },
223
+ { id: 'MCP-01', text: 'MCP server must respond to health_check within 10 seconds' },
224
+ { id: 'QUICK-01', text: 'Quick tasks must create atomic commits for each logical change' },
225
+ ];
226
+
227
+ console.log(`\nTest 2: Known good invariants pass regex (${goodInvariants.length} entries)`);
228
+ let goodPassed = 0;
229
+ let goodFailed = [];
230
+ for (const entry of goodInvariants) {
231
+ const result = regexPass(entry.text);
232
+ if (!result.matched) {
233
+ goodPassed++;
234
+ } else {
235
+ goodFailed.push({ id: entry.id, reason: result.reason });
236
+ }
237
+ }
238
+ console.log(` Passed: ${goodPassed}/${goodInvariants.length}`);
239
+ if (goodFailed.length > 0) {
240
+ console.log(` FALSE POSITIVES:`);
241
+ for (const f of goodFailed) {
242
+ console.log(` ${f.id}: incorrectly rejected — "${f.reason}"`);
243
+ }
244
+ }
245
+
246
+ // Test 3: isBorderline heuristic
247
+ console.log('\nTest 3: Borderline detection heuristic');
248
+ const borderlineCases = [
249
+ { text: 'R3.6 quorum enforcement reviewed and improvement areas identified', expected: true },
250
+ { text: 'Quorum enforcement must block plan execution when quorum is not met', expected: false },
251
+ { text: 'All source files updated to use new slot names', expected: true },
252
+ { text: 'Circuit breaker activates when 3+ alternating groups detected', expected: false },
253
+ ];
254
+ let heuristicCorrect = 0;
255
+ for (const tc of borderlineCases) {
256
+ const result = isBorderline(tc.text);
257
+ if (result === tc.expected) {
258
+ heuristicCorrect++;
259
+ } else {
260
+ console.log(` MISMATCH: "${tc.text.slice(0, 50)}..." — expected ${tc.expected}, got ${result}`);
261
+ }
262
+ }
263
+ console.log(` Correct: ${heuristicCorrect}/${borderlineCases.length}`);
264
+
265
+ // Summary
266
+ const totalTests = nonInvariants.length + goodInvariants.length + borderlineCases.length;
267
+ const totalCorrect = regexCaught + goodPassed + heuristicCorrect;
268
+ console.log(`\n=== Summary: ${totalCorrect}/${totalTests} correct ===`);
269
+
270
+ if (regexMissed.length > 0) {
271
+ console.log(`\nNote: ${regexMissed.length} archived non-invariants not caught by regex.`);
272
+ console.log('These would be classified by a Haiku sub-agent in the workflow.');
273
+ }
274
+ }
275
+
276
+ // ─────────────────────────────────────────────────────────────────────────────
277
+ // CLI entrypoint
278
+ // ─────────────────────────────────────────────────────────────────────────────
279
+
280
+ function main() {
281
+ const args = {};
282
+ for (let i = 2; i < process.argv.length; i++) {
283
+ const arg = process.argv[i];
284
+ if (arg.startsWith('--')) {
285
+ const eqIdx = arg.indexOf('=');
286
+ if (eqIdx !== -1) {
287
+ args[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
288
+ } else {
289
+ args[arg.slice(2)] = true;
290
+ }
291
+ }
292
+ }
293
+
294
+ // --test: run built-in test suite
295
+ if (args.test) {
296
+ runTests();
297
+ process.exit(0);
298
+ }
299
+
300
+ // --batch: validate entire envelope
301
+ if (args.batch) {
302
+ const envelopePath = args.envelope || '.planning/formal/requirements.json';
303
+ if (!fs.existsSync(envelopePath)) {
304
+ console.error(`Envelope not found: ${envelopePath}`);
305
+ process.exit(1);
306
+ }
307
+
308
+ const envelope = JSON.parse(fs.readFileSync(envelopePath, 'utf8'));
309
+ const requirements = envelope.requirements || [];
310
+
311
+ console.log(`Validating ${requirements.length} requirements...\n`);
312
+
313
+ const results = validateInvariantBatch(requirements);
314
+ const nonInvariants = results.filter(r => r.verdict === 'NON_INVARIANT');
315
+ const borderline = results.filter(r => r.verdict === 'BORDERLINE');
316
+
317
+ if (nonInvariants.length === 0 && borderline.length === 0) {
318
+ console.log('All requirements passed invariant gate.');
319
+ } else {
320
+ if (nonInvariants.length > 0) {
321
+ console.log(`NON-INVARIANTS DETECTED (${nonInvariants.length}):\n`);
322
+ for (const ni of nonInvariants) {
323
+ const req = requirements.find(r => r.id === ni.id);
324
+ console.log(` ${ni.id}: ${req?.text?.slice(0, 80)}...`);
325
+ console.log(` Reason: ${ni.reason} [${ni.layer}]`);
326
+ }
327
+ }
328
+ if (borderline.length > 0) {
329
+ console.log(`\nBORDERLINE — need Haiku sub-agent review (${borderline.length}):`);
330
+ for (const b of borderline) {
331
+ const req = requirements.find(r => r.id === b.id);
332
+ console.log(` ${b.id}: ${req?.text?.slice(0, 80)}...`);
333
+ }
334
+ }
335
+ }
336
+
337
+ // --strict: archive non-invariants and remove from envelope
338
+ if (args.strict && nonInvariants.length > 0) {
339
+ const archivePath = args['archive-path'] || '.planning/formal/archived-non-invariants.json';
340
+ let archive = { archived_at: null, reason: '', entries: [] };
341
+ if (fs.existsSync(archivePath)) {
342
+ archive = JSON.parse(fs.readFileSync(archivePath, 'utf8'));
343
+ }
344
+
345
+ // Add any non-invariants not yet in the archive
346
+ const existingIds = new Set((archive.entries || []).map(e => e.id));
347
+ const toArchive = nonInvariants
348
+ .map(ni => requirements.find(r => r.id === ni.id))
349
+ .filter(r => r && !existingIds.has(r.id));
350
+
351
+ if (toArchive.length > 0) {
352
+ archive.entries = [...(archive.entries || []), ...toArchive];
353
+ archive.archived_at = new Date().toISOString();
354
+ archive.reason = archive.reason || 'Non-invariant entries removed by invariant gate';
355
+
356
+ const dir = path.dirname(archivePath);
357
+ const tmpArch = path.join(dir, '.archived-non-invariants.json.tmp');
358
+ fs.writeFileSync(tmpArch, JSON.stringify(archive, null, 2) + '\n', 'utf8');
359
+ fs.renameSync(tmpArch, archivePath);
360
+ console.log(`\nArchived ${toArchive.length} new non-invariants to ${archivePath}`);
361
+ }
362
+
363
+ // Always remove non-invariants from envelope (even if already in archive)
364
+ const nonInvariantIds = new Set(nonInvariants.map(ni => ni.id));
365
+ envelope.requirements = requirements.filter(r => !nonInvariantIds.has(r.id));
366
+
367
+ const dir = path.dirname(envelopePath);
368
+ const tmpEnv = path.join(dir, '.requirements.json.tmp');
369
+ fs.writeFileSync(tmpEnv, JSON.stringify(envelope, null, 2) + '\n', 'utf8');
370
+ fs.renameSync(tmpEnv, envelopePath);
371
+
372
+ console.log(`Envelope reduced: ${requirements.length} → ${envelope.requirements.length}`);
373
+ }
374
+
375
+ const invariantCount = results.length - nonInvariants.length - borderline.length;
376
+ console.log(`\nSummary: ${nonInvariants.length} non-invariant, ${borderline.length} borderline, ${invariantCount} invariant`);
377
+ process.exit(0);
378
+ }
379
+
380
+ // Single requirement mode
381
+ if (!args.id || !args.text) {
382
+ console.error('Usage: node bin/validate-invariant.cjs --id=ID --text="requirement text"');
383
+ console.error(' node bin/validate-invariant.cjs --batch [--envelope=path] [--strict]');
384
+ console.error(' node bin/validate-invariant.cjs --test');
385
+ process.exit(1);
386
+ }
387
+
388
+ const result = validateInvariant({ id: args.id, text: args.text });
389
+
390
+ if (result.verdict === 'INVARIANT') {
391
+ console.log('Invariant check: PASS');
392
+ } else if (result.verdict === 'BORDERLINE') {
393
+ console.log('Invariant check: BORDERLINE — needs Haiku sub-agent classification');
394
+ console.log(` Haiku prompt: ${buildHaikuPrompt(args.id, args.text).slice(0, 100)}...`);
395
+ } else {
396
+ console.log('Invariant check: FAIL');
397
+ console.log(` Reason: ${result.reason}`);
398
+ console.log(` Layer: ${result.layer}`);
399
+ }
400
+
401
+ process.exit(0);
402
+ }
403
+
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+ // Exports
406
+ // ─────────────────────────────────────────────────────────────────────────────
407
+
408
+ module.exports = {
409
+ regexPass,
410
+ isBorderline,
411
+ buildHaikuPrompt,
412
+ validateInvariant,
413
+ validateInvariantBatch,
414
+ INVARIANT_LANGUAGE,
415
+ };
416
+
417
+ if (require.main === module) {
418
+ main();
419
+ }