@opengsd/gsd-core 1.4.2 → 1.5.0-rc.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 (153) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -3
  3. package/agents/gsd-executor.md +1 -1
  4. package/agents/gsd-plan-checker.md +11 -2
  5. package/agents/gsd-planner.md +11 -7
  6. package/agents/gsd-verifier.md +3 -3
  7. package/bin/install.js +947 -149
  8. package/commands/gsd/autonomous.md +5 -1
  9. package/commands/gsd/ns-manage.md +8 -1
  10. package/commands/gsd/ns-project.md +5 -0
  11. package/commands/gsd/ns-review.md +4 -1
  12. package/commands/gsd/ns-workflow.md +7 -1
  13. package/commands/gsd/plan-review-convergence.md +6 -5
  14. package/commands/gsd/surface.md +12 -5
  15. package/gemini-extension.json +1 -1
  16. package/gsd-core/bin/gsd-tools.cjs +198 -101
  17. package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
  18. package/gsd-core/bin/lib/capability-registry.cjs +417 -0
  19. package/gsd-core/bin/lib/capability-state.cjs +336 -0
  20. package/gsd-core/bin/lib/cli-exit.cjs +22 -3
  21. package/gsd-core/bin/lib/config-loader.cjs +716 -0
  22. package/gsd-core/bin/lib/configuration.cjs +4 -34
  23. package/gsd-core/bin/lib/core-utils.cjs +198 -0
  24. package/gsd-core/bin/lib/core.cjs +57 -1817
  25. package/gsd-core/bin/lib/federated-config.cjs +182 -0
  26. package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
  27. package/gsd-core/bin/lib/init.cjs +58 -12
  28. package/gsd-core/bin/lib/install-profiles.cjs +157 -3
  29. package/gsd-core/bin/lib/installer-migrations/004-prune-stale-pristine-snapshots.cjs +123 -0
  30. package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
  31. package/gsd-core/bin/lib/io.cjs +167 -0
  32. package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
  33. package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
  34. package/gsd-core/bin/lib/model-resolver.cjs +426 -0
  35. package/gsd-core/bin/lib/phase-id.cjs +215 -0
  36. package/gsd-core/bin/lib/phase-locator.cjs +148 -0
  37. package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
  38. package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
  39. package/gsd-core/bin/lib/roadmap.cjs +4 -1
  40. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +96 -9
  41. package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +2 -1
  42. package/gsd-core/bin/lib/runtime-homes.cjs +40 -0
  43. package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -0
  44. package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
  45. package/gsd-core/bin/lib/state.cjs +114 -2
  46. package/gsd-core/bin/lib/surface.cjs +66 -14
  47. package/gsd-core/bin/lib/verify.cjs +1 -1
  48. package/gsd-core/bin/shared/model-catalog.json +5 -0
  49. package/gsd-core/bin/shared/runtime-aliases.manifest.json +3 -0
  50. package/gsd-core/bin/verify-reapply-patches.cjs +57 -7
  51. package/gsd-core/references/planner-reviews.md +5 -2
  52. package/gsd-core/templates/phase-prompt.md +7 -7
  53. package/gsd-core/templates/project.md +19 -2
  54. package/gsd-core/templates/summary-complex.md +1 -0
  55. package/gsd-core/templates/summary-minimal.md +1 -0
  56. package/gsd-core/templates/summary-standard.md +1 -0
  57. package/gsd-core/templates/summary.md +1 -0
  58. package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
  59. package/gsd-core/workflows/add-backlog.md +1 -1
  60. package/gsd-core/workflows/add-phase.md +1 -1
  61. package/gsd-core/workflows/add-tests.md +1 -1
  62. package/gsd-core/workflows/add-todo.md +1 -1
  63. package/gsd-core/workflows/ai-integration-phase.md +1 -1
  64. package/gsd-core/workflows/audit-fix.md +1 -1
  65. package/gsd-core/workflows/audit-milestone.md +1 -1
  66. package/gsd-core/workflows/audit-uat.md +1 -1
  67. package/gsd-core/workflows/autonomous.md +86 -15
  68. package/gsd-core/workflows/check-todos.md +1 -1
  69. package/gsd-core/workflows/cleanup.md +1 -1
  70. package/gsd-core/workflows/code-review-fix.md +6 -4
  71. package/gsd-core/workflows/code-review.md +3 -2
  72. package/gsd-core/workflows/complete-milestone.md +11 -5
  73. package/gsd-core/workflows/debug.md +1 -1
  74. package/gsd-core/workflows/diagnose-issues.md +1 -1
  75. package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
  76. package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
  77. package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
  78. package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
  79. package/gsd-core/workflows/discuss-phase.md +8 -1
  80. package/gsd-core/workflows/do.md +1 -1
  81. package/gsd-core/workflows/docs-update.md +1 -1
  82. package/gsd-core/workflows/edit-phase.md +1 -1
  83. package/gsd-core/workflows/eval-review.md +4 -1
  84. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
  85. package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
  86. package/gsd-core/workflows/execute-phase.md +8 -1
  87. package/gsd-core/workflows/execute-plan.md +1 -1
  88. package/gsd-core/workflows/explore.md +1 -1
  89. package/gsd-core/workflows/extract-learnings.md +1 -1
  90. package/gsd-core/workflows/forensics.md +1 -1
  91. package/gsd-core/workflows/graduation.md +1 -1
  92. package/gsd-core/workflows/health.md +1 -1
  93. package/gsd-core/workflows/help/modes/full.md +1 -1
  94. package/gsd-core/workflows/import.md +1 -1
  95. package/gsd-core/workflows/ingest-docs.md +1 -1
  96. package/gsd-core/workflows/insert-phase.md +1 -1
  97. package/gsd-core/workflows/list-workspaces.md +1 -1
  98. package/gsd-core/workflows/manager.md +1 -1
  99. package/gsd-core/workflows/map-codebase.md +1 -1
  100. package/gsd-core/workflows/milestone-summary.md +1 -1
  101. package/gsd-core/workflows/mvp-phase.md +1 -1
  102. package/gsd-core/workflows/new-milestone.md +1 -1
  103. package/gsd-core/workflows/new-project.md +1 -1
  104. package/gsd-core/workflows/new-workspace.md +1 -1
  105. package/gsd-core/workflows/next.md +1 -1
  106. package/gsd-core/workflows/pause-work.md +1 -1
  107. package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
  108. package/gsd-core/workflows/plan-phase.md +27 -2
  109. package/gsd-core/workflows/plan-review-convergence.md +76 -63
  110. package/gsd-core/workflows/plant-seed.md +1 -1
  111. package/gsd-core/workflows/profile-user.md +1 -1
  112. package/gsd-core/workflows/progress.md +1 -1
  113. package/gsd-core/workflows/quick.md +2 -2
  114. package/gsd-core/workflows/reapply-patches.md +18 -1
  115. package/gsd-core/workflows/remove-phase.md +1 -1
  116. package/gsd-core/workflows/remove-workspace.md +1 -1
  117. package/gsd-core/workflows/resume-project.md +1 -1
  118. package/gsd-core/workflows/review.md +1 -1
  119. package/gsd-core/workflows/scan.md +1 -1
  120. package/gsd-core/workflows/secure-phase.md +1 -1
  121. package/gsd-core/workflows/settings-advanced.md +1 -1
  122. package/gsd-core/workflows/settings-integrations.md +1 -1
  123. package/gsd-core/workflows/settings.md +1 -1
  124. package/gsd-core/workflows/ship.md +8 -1
  125. package/gsd-core/workflows/sketch-wrap-up.md +1 -1
  126. package/gsd-core/workflows/sketch.md +1 -1
  127. package/gsd-core/workflows/spec-phase.md +1 -1
  128. package/gsd-core/workflows/spike-wrap-up.md +1 -1
  129. package/gsd-core/workflows/spike.md +1 -1
  130. package/gsd-core/workflows/stats.md +1 -1
  131. package/gsd-core/workflows/thread.md +1 -1
  132. package/gsd-core/workflows/transition.md +1 -1
  133. package/gsd-core/workflows/ui-phase.md +1 -1
  134. package/gsd-core/workflows/ui-review.md +1 -1
  135. package/gsd-core/workflows/ultraplan-phase.md +1 -1
  136. package/gsd-core/workflows/update.md +20 -15
  137. package/gsd-core/workflows/validate-phase.md +1 -1
  138. package/gsd-core/workflows/verify-phase.md +1 -1
  139. package/gsd-core/workflows/verify-work.md +8 -1
  140. package/package.json +7 -2
  141. package/scripts/base64-scan.sh +1 -1
  142. package/scripts/changeset/cli.cjs +9 -2
  143. package/scripts/changeset/lint.cjs +38 -2
  144. package/scripts/ci-test-scope.cjs +21 -10
  145. package/scripts/gen-capability-registry.cjs +1808 -0
  146. package/scripts/gen-loop-host-contract.cjs +471 -0
  147. package/scripts/lint-regression-test-names.allowlist.json +269 -0
  148. package/scripts/lint-regression-test-names.cjs +117 -0
  149. package/scripts/lint-test-file-count.allowlist.json +12 -4
  150. package/scripts/prompt-injection-scan.sh +4 -4
  151. package/scripts/run-tests.cjs +79 -16
  152. package/scripts/secret-scan.sh +3 -3
  153. package/scripts/run-cross-platform-tests.cjs +0 -67
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * gen-loop-host-contract.cjs — generates gsd-core/bin/lib/loop-host-contract.cjs
6
+ * from the <!-- gsd:loop-host ... --> blocks in the five step workflows.
7
+ *
8
+ * Usage:
9
+ * node scripts/gen-loop-host-contract.cjs # print to stdout
10
+ * node scripts/gen-loop-host-contract.cjs --write # write loop-host-contract.cjs
11
+ * node scripts/gen-loop-host-contract.cjs --check # exit 1 if committed file is stale
12
+ *
13
+ * ADR-894 phase 3a-impl-2. Parses structured markers from workflow files,
14
+ * cross-checks declared agent-roles against actual agent references in each
15
+ * workflow, asserts that the union of all points equals the 12 canonical points,
16
+ * and emits a committed CommonJS module exporting the contract array.
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+
22
+ const { ExitError, runMain } = require('./lib/cli-exit.cjs');
23
+
24
+ const ROOT = path.resolve(__dirname, '..');
25
+ const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows');
26
+ const CONTRACT_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'loop-host-contract.cjs');
27
+
28
+ // The five step workflows in pipeline order
29
+ const STEP_WORKFLOWS = [
30
+ { file: 'discuss-phase.md', step: 'discuss' },
31
+ { file: 'plan-phase.md', step: 'plan' },
32
+ { file: 'execute-phase.md', step: 'execute' },
33
+ { file: 'verify-work.md', step: 'verify' },
34
+ { file: 'ship.md', step: 'ship' },
35
+ ];
36
+
37
+ // Canonical 12 loop points in pipeline order
38
+ const CANONICAL_POINTS = [
39
+ 'discuss:pre',
40
+ 'discuss:post',
41
+ 'plan:pre',
42
+ 'plan:post',
43
+ 'execute:pre',
44
+ 'execute:wave:pre',
45
+ 'execute:wave:post',
46
+ 'execute:post',
47
+ 'verify:pre',
48
+ 'verify:post',
49
+ 'ship:pre',
50
+ 'ship:post',
51
+ ];
52
+
53
+ // FIX 1: Per-step canonical point ownership. Each step must declare exactly these points.
54
+ const EXPECTED_POINTS_BY_STEP = {
55
+ discuss: ['discuss:pre', 'discuss:post'],
56
+ plan: ['plan:pre', 'plan:post'],
57
+ execute: ['execute:pre', 'execute:wave:pre', 'execute:wave:post', 'execute:post'],
58
+ verify: ['verify:pre', 'verify:post'],
59
+ ship: ['ship:pre', 'ship:post'],
60
+ };
61
+
62
+ // Role → agent-name mapping used for cross-check.
63
+ // Each non-orchestrator role must correspond to an actual agent reference in
64
+ // the workflow file (e.g. gsd-planner, gsd-executor, gsd-verifier, etc.).
65
+ const ROLE_TO_AGENT = {
66
+ researcher: 'gsd-phase-researcher',
67
+ planner: 'gsd-planner',
68
+ checker: 'gsd-plan-checker',
69
+ executor: 'gsd-executor',
70
+ verifier: 'gsd-verifier',
71
+ };
72
+
73
+ // ─── Parser ───────────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Parse a single <!-- gsd:loop-host ... --> block from file content.
77
+ * Returns a plain object with keys: step, points[], agentRoles[], produces[], consumes[].
78
+ * Throws a descriptive error if the block is malformed or missing.
79
+ *
80
+ * Block format (one key: value per line, comma-separated list values):
81
+ * <!-- gsd:loop-host
82
+ * step: plan
83
+ * points: plan:pre, plan:post
84
+ * agent-roles: researcher, planner, checker
85
+ * produces: PLAN.md
86
+ * consumes: CONTEXT.md
87
+ * -->
88
+ *
89
+ * For empty list values (e.g. "consumes:") the field is an empty array.
90
+ *
91
+ * @param {string} content File content
92
+ * @param {string} fileName For error messages
93
+ * @returns {{ step: string, points: string[], agentRoles: string[], coreArtifacts: { produces: string[], consumes: string[] } }}
94
+ */
95
+ function parseLoopHostBlock(content, fileName) {
96
+ // FIX 2: Detect ALL marker blocks — more than one is a hard error.
97
+ const blockRe = /<!--\s*gsd:loop-host\s*([\s\S]*?)-->/g;
98
+ const allMatches = Array.from(content.matchAll(blockRe));
99
+ if (allMatches.length === 0) {
100
+ throw new Error(fileName + ': missing <!-- gsd:loop-host ... --> block');
101
+ }
102
+ if (allMatches.length > 1) {
103
+ throw new Error(
104
+ fileName + ': expected exactly one gsd:loop-host marker block, found ' + allMatches.length,
105
+ );
106
+ }
107
+
108
+ const blockBody = allMatches[0][1];
109
+
110
+ // FIX 2: Detect duplicate keys within the block.
111
+ const RECOGNIZED_KEYS = ['step', 'points', 'agent-roles', 'produces', 'consumes'];
112
+ const keyCounts = {};
113
+ for (const line of blockBody.split('\n')) {
114
+ const trimmed = line.trim();
115
+ for (const key of RECOGNIZED_KEYS) {
116
+ if (trimmed === key + ':' || trimmed.startsWith(key + ': ') || trimmed.startsWith(key + ':')) {
117
+ keyCounts[key] = (keyCounts[key] || 0) + 1;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ for (const key of RECOGNIZED_KEYS) {
123
+ if (keyCounts[key] > 1) {
124
+ throw new Error(fileName + ': duplicate key \'' + key + '\' in gsd:loop-host marker');
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Parse a field line: "key: value1, value2" → [value1, value2] (trimmed, empty strings removed)
130
+ */
131
+ function parseField(key) {
132
+ // Split on newlines and find the line starting with "key:"
133
+ const lines = blockBody.split('\n');
134
+ for (const line of lines) {
135
+ const trimmed = line.trim();
136
+ if (trimmed === key + ':' || trimmed.startsWith(key + ': ') || trimmed.startsWith(key + ':')) {
137
+ const colonIdx = trimmed.indexOf(':');
138
+ const raw = trimmed.slice(colonIdx + 1).trim();
139
+ if (raw === '') return [];
140
+ return raw.split(',').map((s) => s.trim()).filter((s) => s.length > 0);
141
+ }
142
+ }
143
+ throw new Error(fileName + ': gsd:loop-host block missing required field "' + key + '"');
144
+ }
145
+
146
+ function parseScalar(key) {
147
+ const lines = blockBody.split('\n');
148
+ for (const line of lines) {
149
+ const trimmed = line.trim();
150
+ if (trimmed === key + ':' || trimmed.startsWith(key + ': ') || trimmed.startsWith(key + ':')) {
151
+ const colonIdx = trimmed.indexOf(':');
152
+ const val = trimmed.slice(colonIdx + 1).trim();
153
+ if (val === '') {
154
+ throw new Error(fileName + ': gsd:loop-host block field "' + key + '" must be a non-empty string');
155
+ }
156
+ return val;
157
+ }
158
+ }
159
+ throw new Error(fileName + ': gsd:loop-host block missing required field "' + key + '"');
160
+ }
161
+
162
+ const step = parseScalar('step');
163
+ const points = parseField('points');
164
+ const agentRoles = parseField('agent-roles');
165
+ const produces = parseField('produces');
166
+ const consumes = parseField('consumes');
167
+
168
+ if (points.length === 0) {
169
+ throw new Error(fileName + ': gsd:loop-host block "points" must have at least one value');
170
+ }
171
+ if (agentRoles.length === 0) {
172
+ throw new Error(fileName + ': gsd:loop-host block "agent-roles" must have at least one value');
173
+ }
174
+
175
+ return {
176
+ step,
177
+ points,
178
+ agentRoles,
179
+ coreArtifacts: { produces, consumes },
180
+ };
181
+ }
182
+
183
+ // ─── Cross-check: declared roles vs. actual agent references ─────────────────
184
+
185
+ /**
186
+ * For each non-orchestrator role in agentRoles, verify the workflow content
187
+ * contains a reference to the corresponding agent name.
188
+ *
189
+ * @param {string} content Full workflow file content
190
+ * @param {string[]} agentRoles Roles declared in the block
191
+ * @param {string} fileName For error messages
192
+ * @returns {string[]} Array of error strings; empty = OK
193
+ */
194
+ /**
195
+ * Escape a string for literal use in a RegExp.
196
+ */
197
+ function escapeRegExp(s) {
198
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
199
+ }
200
+
201
+ function crossCheckRoles(content, agentRoles, fileName) {
202
+ const errors = [];
203
+ for (const role of agentRoles) {
204
+ if (role === 'orchestrator') continue; // orchestrator = host itself; no agent file needed
205
+ const agentName = ROLE_TO_AGENT[role];
206
+ if (!agentName) {
207
+ errors.push(
208
+ fileName + ': declared agent-role "' + role + '" has no entry in ROLE_TO_AGENT mapping',
209
+ );
210
+ continue;
211
+ }
212
+ // FIX 3: Use word-boundary match so "gsd-plan-checker-v2" does NOT satisfy a required
213
+ // "gsd-plan-checker". Treat '-' as part of the token: boundary = start/end of string or
214
+ // a character that is neither \w nor '-'.
215
+ // Note: this is a presence check (any reference in the file), not a spawn-site check —
216
+ // a known limitation; spawn-site checks would require AST-level analysis.
217
+ const agentRe = new RegExp(
218
+ '(^|[^\\w-])' + escapeRegExp(agentName) + '($|[^\\w-])',
219
+ );
220
+ if (!agentRe.test(content)) {
221
+ errors.push(
222
+ fileName + ': declared agent-role "' + role + '" maps to agent "' + agentName +
223
+ '" but "' + agentName + '" is not referenced anywhere in the workflow file',
224
+ );
225
+ }
226
+ }
227
+ return errors;
228
+ }
229
+
230
+ // ─── 12-points coverage assertion ────────────────────────────────────────────
231
+
232
+ /**
233
+ * Assert that the union of all points across all contract entries equals
234
+ * exactly the 12 canonical points (no more, no fewer), AND that each step
235
+ * declares exactly its own canonical points (FIX 1: per-step ownership).
236
+ *
237
+ * @param {{ step: string, points: string[] }[]} entries
238
+ * @returns {string[]} Error strings; empty = OK
239
+ */
240
+ function assertPointsCoverage(entries) {
241
+ const errors = [];
242
+
243
+ // FIX 1: Per-step ownership check — each step must declare exactly its own canonical points.
244
+ for (const entry of entries) {
245
+ const expected = EXPECTED_POINTS_BY_STEP[entry.step];
246
+ if (!expected) continue; // unknown step — caught elsewhere
247
+ const expectedSet = new Set(expected);
248
+ const actualSet = new Set(entry.points);
249
+ let mismatch = false;
250
+ for (const p of expectedSet) {
251
+ if (!actualSet.has(p)) mismatch = true;
252
+ }
253
+ for (const p of actualSet) {
254
+ if (!expectedSet.has(p)) mismatch = true;
255
+ }
256
+ if (mismatch) {
257
+ errors.push(
258
+ 'step "' + entry.step + '" declares points [' + entry.points.join(', ') +
259
+ '] but expected [' + expected.join(', ') + ']',
260
+ );
261
+ }
262
+ }
263
+
264
+ // Global union + duplicate check (belt and suspenders alongside per-step check).
265
+ const allPoints = new Set();
266
+ for (const entry of entries) {
267
+ for (const p of entry.points) {
268
+ if (allPoints.has(p)) {
269
+ errors.push('point "' + p + '" declared more than once across all step workflows');
270
+ }
271
+ allPoints.add(p);
272
+ }
273
+ }
274
+
275
+ const canonical = new Set(CANONICAL_POINTS);
276
+ for (const p of allPoints) {
277
+ if (!canonical.has(p)) {
278
+ errors.push('declared point "' + p + '" is not in the canonical 12-point set');
279
+ }
280
+ }
281
+ for (const p of canonical) {
282
+ if (!allPoints.has(p)) {
283
+ errors.push('canonical point "' + p + '" is not declared in any step workflow');
284
+ }
285
+ }
286
+ return errors;
287
+ }
288
+
289
+ // ─── Contract builder ─────────────────────────────────────────────────────────
290
+
291
+ /**
292
+ * Read and parse all five step workflows. Returns the contract array.
293
+ * Throws on any parse or cross-check error.
294
+ *
295
+ * @param {string} [workflowsDir] Override for testing
296
+ * @returns {{ step: string, points: string[], agentRoles: string[], coreArtifacts: { produces: string[], consumes: string[] } }[]}
297
+ */
298
+ function buildContract(workflowsDir) {
299
+ const resolvedDir = workflowsDir !== undefined ? workflowsDir : WORKFLOWS_DIR;
300
+ const contract = [];
301
+ const allErrors = [];
302
+
303
+ for (const { file, step } of STEP_WORKFLOWS) {
304
+ const filePath = path.join(resolvedDir, file);
305
+ let content;
306
+ try {
307
+ content = fs.readFileSync(filePath, 'utf8');
308
+ } catch (err) {
309
+ allErrors.push('Could not read ' + file + ': ' + String(err.message));
310
+ continue;
311
+ }
312
+
313
+ let entry;
314
+ try {
315
+ entry = parseLoopHostBlock(content, file);
316
+ } catch (err) {
317
+ allErrors.push(String(err.message));
318
+ continue;
319
+ }
320
+
321
+ // Validate the declared step matches the expected step for this file
322
+ if (entry.step !== step) {
323
+ allErrors.push(
324
+ file + ': gsd:loop-host block declares step "' + entry.step +
325
+ '" but expected "' + step + '"',
326
+ );
327
+ }
328
+
329
+ // Cross-check roles
330
+ const roleErrors = crossCheckRoles(content, entry.agentRoles, file);
331
+ allErrors.push(...roleErrors);
332
+
333
+ contract.push(entry);
334
+ }
335
+
336
+ if (allErrors.length > 0) {
337
+ throw new Error('Loop host contract generation failed:\n' + allErrors.map((e) => ' ' + e).join('\n'));
338
+ }
339
+
340
+ // Assert 12-points coverage
341
+ const pointErrors = assertPointsCoverage(contract);
342
+ if (pointErrors.length > 0) {
343
+ throw new Error('Loop host contract points coverage failed:\n' + pointErrors.map((e) => ' ' + e).join('\n'));
344
+ }
345
+
346
+ return contract;
347
+ }
348
+
349
+ // ─── Serialization ────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Serialize the contract array to a CommonJS module string.
353
+ *
354
+ * @param {object[]} contract
355
+ * @returns {string}
356
+ */
357
+ function serializeContract(contract) {
358
+ const lines = [];
359
+
360
+ lines.push("'use strict';");
361
+ lines.push('');
362
+ lines.push('/**');
363
+ lines.push(' * loop-host-contract.cjs — generated by scripts/gen-loop-host-contract.cjs');
364
+ lines.push(' * DO NOT EDIT BY HAND. Run: node scripts/gen-loop-host-contract.cjs --write');
365
+ lines.push(' * ADR-894 §3 — Loop Host Contract, generated from workflow markers.');
366
+ lines.push(' * 12 points: discuss:pre/post, plan:pre/post, execute:pre/wave:pre/wave:post/post,');
367
+ lines.push(' * verify:pre/post, ship:pre/post. Per-step agentRoles and coreArtifacts.');
368
+ lines.push(' */');
369
+ lines.push('');
370
+ lines.push('const LOOP_HOST_CONTRACT = ' + JSON.stringify(contract, null, 2) + ';');
371
+ lines.push('');
372
+ lines.push('module.exports = { LOOP_HOST_CONTRACT };');
373
+ lines.push('');
374
+
375
+ return lines.join('\n');
376
+ }
377
+
378
+ // ─── --check diff helper ──────────────────────────────────────────────────────
379
+
380
+ /**
381
+ * Normalize line endings to LF for CRLF-agnostic comparison.
382
+ * FIX 4: The serializer has no nondeterministic content (no timestamp), so
383
+ * the generated-by-line stripping that was here has been removed — full content
384
+ * comparison is now used so header drift is caught by --check.
385
+ *
386
+ * @param {string} content
387
+ * @returns {string}
388
+ */
389
+ function normalizeLineEndings(content) {
390
+ return content.replace(/\r/g, '');
391
+ }
392
+
393
+ // ─── Main ─────────────────────────────────────────────────────────────────────
394
+
395
+ function main() {
396
+ const flag = process.argv[2];
397
+
398
+ if (flag === '--check') {
399
+ let contract;
400
+ try {
401
+ contract = buildContract();
402
+ } catch (err) {
403
+ process.stderr.write(String(err.message) + '\n');
404
+ throw new ExitError(1, 'loop-host contract generation failed');
405
+ }
406
+ const live = serializeContract(contract);
407
+
408
+ if (!fs.existsSync(CONTRACT_PATH)) {
409
+ process.stderr.write(
410
+ 'gsd-core/bin/lib/loop-host-contract.cjs does not exist. Run:\n' +
411
+ ' node scripts/gen-loop-host-contract.cjs --write\n',
412
+ );
413
+ throw new ExitError(1);
414
+ }
415
+
416
+ const committed = fs.readFileSync(CONTRACT_PATH, 'utf8');
417
+ // FIX 4: Compare full content (no generated-by stripping) so header drift is caught.
418
+ if (normalizeLineEndings(committed) !== normalizeLineEndings(live)) {
419
+ process.stderr.write(
420
+ 'gsd-core/bin/lib/loop-host-contract.cjs is stale. Run:\n' +
421
+ ' node scripts/gen-loop-host-contract.cjs --write\n',
422
+ );
423
+ throw new ExitError(1);
424
+ }
425
+
426
+ process.stdout.write('gsd-core/bin/lib/loop-host-contract.cjs is up to date.\n');
427
+ } else if (flag === '--write') {
428
+ let contract;
429
+ try {
430
+ contract = buildContract();
431
+ } catch (err) {
432
+ process.stderr.write(String(err.message) + '\n');
433
+ throw new ExitError(1, 'loop-host contract generation failed — file not written');
434
+ }
435
+ const content = serializeContract(contract);
436
+ fs.mkdirSync(path.dirname(CONTRACT_PATH), { recursive: true });
437
+ fs.writeFileSync(CONTRACT_PATH, content, 'utf8');
438
+ process.stdout.write('Wrote ' + CONTRACT_PATH + '\n');
439
+ } else {
440
+ // Default: print to stdout
441
+ let contract;
442
+ try {
443
+ contract = buildContract();
444
+ } catch (err) {
445
+ process.stderr.write(String(err.message) + '\n');
446
+ throw new ExitError(1, 'loop-host contract generation failed');
447
+ }
448
+ process.stdout.write(serializeContract(contract) + '\n');
449
+ }
450
+ }
451
+
452
+ // ─── Exports (for tests) ─────────────────────────────────────────────────────
453
+
454
+ module.exports = {
455
+ parseLoopHostBlock,
456
+ crossCheckRoles,
457
+ assertPointsCoverage,
458
+ buildContract,
459
+ serializeContract,
460
+ normalizeLineEndings,
461
+ STEP_WORKFLOWS,
462
+ CANONICAL_POINTS,
463
+ EXPECTED_POINTS_BY_STEP,
464
+ ROLE_TO_AGENT,
465
+ };
466
+
467
+ // ─── CLI entry point ──────────────────────────────────────────────────────────
468
+
469
+ if (require.main === module) {
470
+ runMain(main);
471
+ }