@opengsd/gsd-core 1.4.4 → 1.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -3
- package/agents/gsd-code-fixer.md +3 -2
- package/agents/gsd-debug-session-manager.md +2 -1
- package/agents/gsd-debugger.md +4 -3
- package/agents/gsd-executor.md +17 -16
- package/agents/gsd-intel-updater.md +38 -41
- package/agents/gsd-phase-researcher.md +8 -8
- package/agents/gsd-plan-checker.md +23 -13
- package/agents/gsd-planner.md +32 -188
- package/agents/gsd-project-researcher.md +5 -4
- package/agents/gsd-research-synthesizer.md +2 -1
- package/agents/gsd-ui-researcher.md +2 -1
- package/agents/gsd-verifier.md +12 -11
- package/bin/install.js +965 -1486
- package/commands/gsd/autonomous.md +5 -1
- package/commands/gsd/ns-manage.md +8 -1
- package/commands/gsd/ns-project.md +5 -0
- package/commands/gsd/ns-review.md +4 -1
- package/commands/gsd/ns-workflow.md +7 -1
- package/commands/gsd/plan-review-convergence.md +5 -4
- package/commands/gsd/surface.md +12 -5
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +198 -101
- package/gsd-core/bin/gsd_run +20 -0
- package/gsd-core/bin/lib/audit-command-router.cjs +61 -0
- package/gsd-core/bin/lib/capability-registry.cjs +2234 -0
- package/gsd-core/bin/lib/capability-state.cjs +336 -0
- package/gsd-core/bin/lib/check-command-router.cjs +133 -2
- package/gsd-core/bin/lib/cli-exit.cjs +22 -3
- package/gsd-core/bin/lib/config-loader.cjs +716 -0
- package/gsd-core/bin/lib/configuration.cjs +4 -34
- package/gsd-core/bin/lib/core-utils.cjs +198 -0
- package/gsd-core/bin/lib/core.cjs +107 -1817
- package/gsd-core/bin/lib/edge-probe.cjs +173 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
- package/gsd-core/bin/lib/federated-config.cjs +182 -0
- package/gsd-core/bin/lib/graphify-command-router.cjs +74 -0
- package/gsd-core/bin/lib/init.cjs +58 -12
- package/gsd-core/bin/lib/install-profiles.cjs +157 -3
- package/gsd-core/bin/lib/intel-command-router.cjs +116 -0
- package/gsd-core/bin/lib/intel.cjs +3 -3
- package/gsd-core/bin/lib/io.cjs +222 -0
- package/gsd-core/bin/lib/loop-host-contract.cjs +105 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +460 -0
- package/gsd-core/bin/lib/model-resolver.cjs +426 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
- package/gsd-core/bin/lib/phase-id.cjs +215 -0
- package/gsd-core/bin/lib/phase-locator.cjs +148 -0
- package/gsd-core/bin/lib/phase.cjs +17 -0
- package/gsd-core/bin/lib/probe-core.cjs +257 -0
- package/gsd-core/bin/lib/profile-pipeline.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +443 -0
- package/gsd-core/bin/lib/roadmap.cjs +44 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +92 -95
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +68 -29
- package/gsd-core/bin/lib/runtime-homes.cjs +163 -87
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +2 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +13 -0
- package/gsd-core/bin/lib/state-document.cjs +8 -0
- package/gsd-core/bin/lib/state.cjs +114 -2
- package/gsd-core/bin/lib/surface.cjs +66 -14
- package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
- package/gsd-core/bin/lib/update-context.cjs +4 -1
- package/gsd-core/bin/lib/verify.cjs +104 -1
- package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
- package/gsd-core/bin/shared/model-catalog.json +11 -6
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -1
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
- package/gsd-core/references/edge-probe.md +261 -0
- package/gsd-core/references/planner-antipatterns.md +41 -0
- package/gsd-core/references/planner-guidance.md +186 -0
- package/gsd-core/references/planner-reviews.md +5 -2
- package/gsd-core/templates/phase-prompt.md +7 -7
- package/gsd-core/templates/project.md +19 -2
- package/gsd-core/templates/spec.md +12 -0
- package/gsd-core/templates/summary-complex.md +1 -0
- package/gsd-core/templates/summary-minimal.md +1 -0
- package/gsd-core/templates/summary-standard.md +1 -0
- package/gsd-core/templates/summary.md +1 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +111 -51
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +6 -4
- package/gsd-core/workflows/code-review.md +53 -17
- package/gsd-core/workflows/complete-milestone.md +11 -5
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +8 -1
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +4 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase.md +8 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/help/modes/full.md +1 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +1 -1
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +9 -1
- package/gsd-core/workflows/new-project.md +9 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
- package/gsd-core/workflows/plan-phase.md +65 -28
- package/gsd-core/workflows/plan-review-convergence.md +60 -33
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +2 -2
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +1 -1
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +7 -7
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ship.md +8 -1
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +130 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +1 -1
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/update.md +2 -2
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +8 -1
- package/package.json +11 -3
- package/scripts/base64-scan.sh +1 -1
- package/scripts/changeset/cli.cjs +8 -1
- package/scripts/changeset/lint.cjs +38 -2
- package/scripts/ci-test-scope.cjs +21 -10
- package/scripts/gen-capability-registry.cjs +2293 -0
- package/scripts/gen-loop-host-contract.cjs +471 -0
- package/scripts/lib/allowlist-ratchet.cjs +101 -1
- package/scripts/lint-regression-test-names.allowlist.json +269 -0
- package/scripts/lint-regression-test-names.cjs +117 -0
- package/scripts/lint-test-file-count.allowlist.json +25 -4
- package/scripts/lint-windows-test-portability.cjs +178 -0
- package/scripts/prompt-injection-scan.sh +4 -4
- package/scripts/research-profiles.cjs +10 -10
- package/scripts/run-tests.cjs +133 -29
- package/scripts/secret-scan.sh +3 -3
- package/scripts/sync-next-version.cjs +133 -0
- package/scripts/sync-runtime-launcher.cjs +21 -5
- package/scripts/update-size-baseline.cjs +68 -0
- package/scripts/workflow-policy.cjs +42 -9
- package/scripts/workflow-size.cjs +90 -0
- 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
|
+
}
|
|
@@ -133,4 +133,104 @@ function assertTightCeiling({ label, actualMax, ceiling, grace, fail }) {
|
|
|
133
133
|
return { ok, slack };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Assert that each artifact's measured size matches a committed per-file
|
|
138
|
+
* baseline snapshot. Growth, shrinkage, additions, and removals are each
|
|
139
|
+
* surfaced by name — there is no aggregate "max" that can mask one file's
|
|
140
|
+
* growth behind another file's size.
|
|
141
|
+
*
|
|
142
|
+
* Fails when, for the union of `current` and `baseline` keys:
|
|
143
|
+
* - `current[name] > baseline[name]` → GROWTH: the file grew past its recorded
|
|
144
|
+
* size. Regenerate the baseline and justify the growth in the PR (or extract
|
|
145
|
+
* the content lazily). This is the headline guard.
|
|
146
|
+
* - `current[name] < baseline[name]` → STALE: the file shrank but the baseline
|
|
147
|
+
* still records the old (larger) size. Regenerate to auto-tighten — the
|
|
148
|
+
* per-file analogue of `assertWithinAllowlist`'s stale-entry rule, so the
|
|
149
|
+
* snapshot can only ratchet downward.
|
|
150
|
+
* - name in `current` but not `baseline` → ADDED: a new artifact with no
|
|
151
|
+
* recorded baseline. Regenerate to record it.
|
|
152
|
+
* - name in `baseline` but not `current` → REMOVED: an orphaned baseline entry
|
|
153
|
+
* whose artifact no longer exists. Regenerate to drop it.
|
|
154
|
+
*
|
|
155
|
+
* ## Why per-file, not a tier max (issue #1074)
|
|
156
|
+
*
|
|
157
|
+
* A `max(group) within grace` ceiling only binds the single largest file in the
|
|
158
|
+
* group; every other file inherits that ceiling and can grow silently beneath
|
|
159
|
+
* it. Recording each file's exact size removes the masking blind spot — the
|
|
160
|
+
* same reason `assertWithinAllowlist` enforces on identity rather than a count
|
|
161
|
+
* (issue #597).
|
|
162
|
+
*
|
|
163
|
+
* @param {object} opts
|
|
164
|
+
* @param {string} opts.label - Human-readable guard name (used in messages).
|
|
165
|
+
* @param {Object<string, number>} opts.current - Measured sizes by name.
|
|
166
|
+
* @param {Object<string, number>} opts.baseline - Committed sizes by name.
|
|
167
|
+
* @param {function(string): void} opts.fail - Callback invoked once per
|
|
168
|
+
* non-empty violation category with a
|
|
169
|
+
* descriptive message. Injected so callers
|
|
170
|
+
* control the failure mode (assert.fail, a
|
|
171
|
+
* thrower, or a collector in unit tests).
|
|
172
|
+
* @param {string} [opts.updateHint] - Optional remediation hint appended to
|
|
173
|
+
* every failure message (e.g. the regen
|
|
174
|
+
* command).
|
|
175
|
+
* @returns {{ grown: Array<{name:string,from:number,to:number,delta:number}>,
|
|
176
|
+
* shrunk: Array<{name:string,from:number,to:number,delta:number}>,
|
|
177
|
+
* added: string[], removed: string[] }}
|
|
178
|
+
* Sorted-by-name breakdown of every difference.
|
|
179
|
+
*/
|
|
180
|
+
function assertFileBaseline({ label, current, baseline, fail, updateHint }) {
|
|
181
|
+
const currentNames = new Set(Object.keys(current));
|
|
182
|
+
const baselineNames = new Set(Object.keys(baseline));
|
|
183
|
+
|
|
184
|
+
const added = [...currentNames].filter((n) => !baselineNames.has(n)).sort();
|
|
185
|
+
const removed = [...baselineNames].filter((n) => !currentNames.has(n)).sort();
|
|
186
|
+
|
|
187
|
+
const grown = [];
|
|
188
|
+
const shrunk = [];
|
|
189
|
+
const shared = [...currentNames].filter((n) => baselineNames.has(n)).sort();
|
|
190
|
+
for (const name of shared) {
|
|
191
|
+
const from = baseline[name];
|
|
192
|
+
const to = current[name];
|
|
193
|
+
if (to > from) grown.push({ name, from, to, delta: to - from });
|
|
194
|
+
else if (to < from) shrunk.push({ name, from, to, delta: from - to });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const hint = updateHint ? `\n${updateHint}` : '';
|
|
198
|
+
|
|
199
|
+
if (grown.length > 0) {
|
|
200
|
+
const list = grown
|
|
201
|
+
.map((g) => ` - ${g.name}: ${g.from} → ${g.to} (+${g.delta})`)
|
|
202
|
+
.join('\n');
|
|
203
|
+
fail(
|
|
204
|
+
`[${label}] ${grown.length} file(s) grew past the committed baseline. ` +
|
|
205
|
+
`Regenerate the baseline and justify the growth in your PR, or extract the content lazily.\n${list}${hint}`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (shrunk.length > 0) {
|
|
210
|
+
const list = shrunk
|
|
211
|
+
.map((s) => ` - ${s.name}: ${s.from} → ${s.to} (-${s.delta})`)
|
|
212
|
+
.join('\n');
|
|
213
|
+
fail(
|
|
214
|
+
`[${label}] ${shrunk.length} file(s) are SMALLER than the baseline — the snapshot is stale ` +
|
|
215
|
+
`and MUST be regenerated so the budget ratchets downward.\n${list}${hint}`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (added.length > 0) {
|
|
220
|
+
const list = added.map((n) => ` - ${n}`).join('\n');
|
|
221
|
+
fail(
|
|
222
|
+
`[${label}] ${added.length} file(s) are not in the baseline — regenerate to record them.\n${list}${hint}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (removed.length > 0) {
|
|
227
|
+
const list = removed.map((n) => ` - ${n}`).join('\n');
|
|
228
|
+
fail(
|
|
229
|
+
`[${label}] ${removed.length} baseline entry(ies) no longer exist — regenerate to drop them.\n${list}${hint}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { grown, shrunk, added, removed };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
module.exports = { assertWithinAllowlist, assertTightCeiling, assertFileBaseline };
|