cleargate 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/MANIFEST.json +30 -16
- package/dist/cli.cjs +486 -51
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +481 -47
- package/dist/cli.js.map +1 -1
- package/dist/templates/cleargate-planning/.claude/agents/architect.md +24 -0
- package/dist/templates/cleargate-planning/.claude/agents/developer.md +24 -0
- package/dist/templates/cleargate-planning/.claude/agents/reporter.md +74 -0
- package/dist/templates/cleargate-planning/.claude/hooks/pre-edit-gate.sh +162 -0
- package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +10 -7
- package/dist/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +9 -8
- package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +36 -13
- package/dist/templates/cleargate-planning/.claude/settings.json +9 -0
- package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +55 -0
- package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +7 -7
- package/dist/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +137 -40
- package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +93 -0
- package/dist/templates/cleargate-planning/.cleargate/scripts/constants.mjs +8 -4
- package/dist/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +9 -1
- package/dist/templates/cleargate-planning/.cleargate/scripts/pre_gate_common.sh +74 -0
- package/dist/templates/cleargate-planning/.cleargate/scripts/pre_gate_runner.sh +65 -1
- package/dist/templates/cleargate-planning/.cleargate/scripts/state.schema.json +31 -8
- package/dist/templates/cleargate-planning/.cleargate/scripts/update_state.mjs +93 -8
- package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +19 -4
- package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +58 -0
- package/dist/templates/cleargate-planning/.cleargate/templates/sprint_report.md +32 -2
- package/dist/templates/cleargate-planning/.cleargate/templates/story.md +3 -1
- package/dist/templates/cleargate-planning/CLAUDE.md +1 -1
- package/dist/templates/cleargate-planning/MANIFEST.json +30 -16
- package/package.json +1 -1
- package/templates/cleargate-planning/.claude/agents/architect.md +24 -0
- package/templates/cleargate-planning/.claude/agents/developer.md +24 -0
- package/templates/cleargate-planning/.claude/agents/reporter.md +74 -0
- package/templates/cleargate-planning/.claude/hooks/pre-edit-gate.sh +162 -0
- package/templates/cleargate-planning/.claude/hooks/session-start.sh +10 -7
- package/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +9 -8
- package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +36 -13
- package/templates/cleargate-planning/.claude/settings.json +9 -0
- package/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +55 -0
- package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +7 -7
- package/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +137 -40
- package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +93 -0
- package/templates/cleargate-planning/.cleargate/scripts/constants.mjs +8 -4
- package/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +9 -1
- package/templates/cleargate-planning/.cleargate/scripts/pre_gate_common.sh +74 -0
- package/templates/cleargate-planning/.cleargate/scripts/pre_gate_runner.sh +65 -1
- package/templates/cleargate-planning/.cleargate/scripts/state.schema.json +31 -8
- package/templates/cleargate-planning/.cleargate/scripts/update_state.mjs +93 -8
- package/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +19 -4
- package/templates/cleargate-planning/.cleargate/templates/hotfix.md +58 -0
- package/templates/cleargate-planning/.cleargate/templates/sprint_report.md +32 -2
- package/templates/cleargate-planning/.cleargate/templates/story.md +3 -1
- package/templates/cleargate-planning/CLAUDE.md +1 -1
- package/templates/cleargate-planning/MANIFEST.json +30 -16
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* assert_story_files.mjs — Gate-2
|
|
3
|
+
* assert_story_files.mjs — Gate-2 work-item file existence + approval assertion
|
|
4
4
|
*
|
|
5
5
|
* Usage: node assert_story_files.mjs <sprint-file-path>
|
|
6
6
|
*
|
|
7
7
|
* Parses the "## 1. Consolidated Deliverables" section of a sprint file for
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* all six work-item id shapes:
|
|
9
|
+
* STORY-\d+-\d+, CR-\d+, BUG-\d+, EPIC-\d+, PROPOSAL-\d+ (PROP-\d+ normalised),
|
|
10
|
+
* HOTFIX-\d+
|
|
11
|
+
* then checks that each has a corresponding pending-sync/<ID>_*.md file under
|
|
12
|
+
* the repo root, and that each present file is approved + structurally non-empty.
|
|
10
13
|
*
|
|
11
|
-
* Exit 0: all
|
|
12
|
-
* Exit 1: one or more missing (prints
|
|
14
|
+
* Exit 0: all work-item files present, approved, and non-empty (prints summary to stdout)
|
|
15
|
+
* Exit 1: one or more missing / unapproved / stub-empty (prints structured stderr)
|
|
13
16
|
* Exit 2: usage / parse error
|
|
14
17
|
*
|
|
15
18
|
* Env:
|
|
16
19
|
* CLEARGATE_REPO_ROOT override repo root (for test isolation)
|
|
20
|
+
* CLEARGATE_EXEC_MODE override execution_mode ('v1'|'v2') — for test isolation
|
|
21
|
+
*
|
|
22
|
+
* Returns (from assertWorkItemFiles):
|
|
23
|
+
* { missing: string[], present: string[], unapproved: string[], empty: string[] }
|
|
17
24
|
*/
|
|
18
25
|
|
|
19
26
|
import fs from 'node:fs';
|
|
@@ -51,37 +58,103 @@ function extractDeliverablesSection(content) {
|
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
/**
|
|
54
|
-
* Extract deduplicated
|
|
61
|
+
* Extract deduplicated work-item IDs from a text block.
|
|
62
|
+
*
|
|
63
|
+
* ID shapes (longest-alternative-first to avoid prefix collisions):
|
|
64
|
+
* STORY-\d+-\d+ (e.g. STORY-022-07)
|
|
65
|
+
* CR-\d+ (e.g. CR-008)
|
|
66
|
+
* BUG-\d+ (e.g. BUG-007)
|
|
67
|
+
* EPIC-\d+ (e.g. EPIC-013)
|
|
68
|
+
* HOTFIX-\d+ (e.g. HOTFIX-001)
|
|
69
|
+
* PROPOSAL-\d+ (e.g. PROPOSAL-013)
|
|
70
|
+
* PROP-\d+ normalised to PROPOSAL-NNN post-extract (BUG-009 lesson)
|
|
71
|
+
*
|
|
72
|
+
* STORY before CR/BUG/EPIC/HOTFIX, PROPOSAL before PROP — BUG-010 longest-first rule.
|
|
55
73
|
*/
|
|
56
|
-
function
|
|
57
|
-
const
|
|
58
|
-
|
|
74
|
+
function extractWorkItemIds(text) {
|
|
75
|
+
const re = /(STORY-\d+-\d+|(CR|BUG|EPIC|HOTFIX)-\d+|(PROPOSAL|PROP)-\d+)/g;
|
|
76
|
+
const raw = [];
|
|
77
|
+
let m;
|
|
78
|
+
while ((m = re.exec(text)) !== null) {
|
|
79
|
+
raw.push(m[0]);
|
|
80
|
+
}
|
|
81
|
+
// BUG-009 normalize: PROP-NNN → PROPOSAL-NNN
|
|
82
|
+
const normalised = raw.map((id) => id.replace(/^PROP-(\d+)$/, 'PROPOSAL-$1'));
|
|
83
|
+
return [...new Set(normalised)];
|
|
59
84
|
}
|
|
60
85
|
|
|
61
86
|
/**
|
|
62
|
-
* Check whether pending-sync contains a file matching
|
|
63
|
-
* Returns the matching
|
|
87
|
+
* Check whether pending-sync OR archive contains a file matching <ID>_*.md
|
|
88
|
+
* Returns the matching absolute path or null.
|
|
64
89
|
*/
|
|
65
|
-
function
|
|
66
|
-
const
|
|
67
|
-
|
|
90
|
+
function findWorkItemFile(repoRoot, workItemId) {
|
|
91
|
+
const searchDirs = [
|
|
92
|
+
path.join(repoRoot, '.cleargate', 'delivery', 'pending-sync'),
|
|
93
|
+
path.join(repoRoot, '.cleargate', 'delivery', 'archive'),
|
|
94
|
+
];
|
|
95
|
+
const prefix = `${workItemId}_`;
|
|
96
|
+
for (const dir of searchDirs) {
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = fs.readdirSync(dir);
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const match = entries.find(
|
|
104
|
+
(e) => e.startsWith(prefix) && e.endsWith('.md')
|
|
105
|
+
);
|
|
106
|
+
if (match) return path.join(dir, match);
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Inline YAML frontmatter extractor.
|
|
113
|
+
* Returns { approved: boolean, has_heading: boolean }.
|
|
114
|
+
*
|
|
115
|
+
* - approved: frontmatter contains `approved: true` (quoted or unquoted).
|
|
116
|
+
* - has_heading: body after frontmatter contains at least one "## " line.
|
|
117
|
+
*
|
|
118
|
+
* Tolerate files with no frontmatter (approved=false, has_heading per body scan).
|
|
119
|
+
*/
|
|
120
|
+
function assertWorkItemApproved(filePath) {
|
|
121
|
+
let content;
|
|
68
122
|
try {
|
|
69
|
-
|
|
123
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
70
124
|
} catch {
|
|
71
|
-
return
|
|
125
|
+
return { approved: false, has_heading: false };
|
|
72
126
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
127
|
+
|
|
128
|
+
let body = content;
|
|
129
|
+
let approved = false;
|
|
130
|
+
|
|
131
|
+
// Match YAML frontmatter block: starts with --- on first line
|
|
132
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
133
|
+
if (fmMatch) {
|
|
134
|
+
const fmBlock = fmMatch[1];
|
|
135
|
+
body = fmMatch[2] ?? '';
|
|
136
|
+
|
|
137
|
+
// Look for `approved: true` (unquoted) or `approved: "true"` (quoted)
|
|
138
|
+
const approvedMatch = fmBlock.match(/^approved:\s*["']?(true)["']?\s*$/m);
|
|
139
|
+
approved = approvedMatch !== null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// has_heading: body contains at least one "## " markdown heading
|
|
143
|
+
const has_heading = /^## /m.test(body);
|
|
144
|
+
|
|
145
|
+
return { approved, has_heading };
|
|
78
146
|
}
|
|
79
147
|
|
|
80
148
|
/**
|
|
81
149
|
* Main assertion logic.
|
|
82
|
-
* Returns { missing: string[], present: string[] }
|
|
150
|
+
* Returns { missing: string[], present: string[], unapproved: string[], empty: string[] }
|
|
151
|
+
*
|
|
152
|
+
* - missing: id referenced in §1 with no file in pending-sync/ or archive/
|
|
153
|
+
* - present: id with a file found
|
|
154
|
+
* - unapproved: present but approved: false in frontmatter
|
|
155
|
+
* - empty: present + approved but body has no ## heading (stub)
|
|
83
156
|
*/
|
|
84
|
-
function
|
|
157
|
+
function assertWorkItemFiles(sprintFilePath, repoRoot) {
|
|
85
158
|
let content;
|
|
86
159
|
try {
|
|
87
160
|
content = fs.readFileSync(sprintFilePath, 'utf8');
|
|
@@ -98,25 +171,34 @@ function assertStoryFiles(sprintFilePath, repoRoot) {
|
|
|
98
171
|
process.exit(2);
|
|
99
172
|
}
|
|
100
173
|
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
process.stderr.write('Warning: no
|
|
174
|
+
const workItemIds = extractWorkItemIds(section);
|
|
175
|
+
if (workItemIds.length === 0) {
|
|
176
|
+
process.stderr.write('Warning: no work-item IDs found in §1 Consolidated Deliverables\n');
|
|
104
177
|
// Return empty — no files to check, nothing is missing
|
|
105
|
-
return { missing: [], present: [] };
|
|
178
|
+
return { missing: [], present: [], unapproved: [], empty: [] };
|
|
106
179
|
}
|
|
107
180
|
|
|
108
181
|
const missing = [];
|
|
109
182
|
const present = [];
|
|
110
|
-
|
|
111
|
-
|
|
183
|
+
const unapproved = [];
|
|
184
|
+
const empty = [];
|
|
185
|
+
|
|
186
|
+
for (const id of workItemIds) {
|
|
187
|
+
const found = findWorkItemFile(repoRoot, id);
|
|
112
188
|
if (found) {
|
|
113
189
|
present.push(id);
|
|
190
|
+
const { approved, has_heading } = assertWorkItemApproved(found);
|
|
191
|
+
if (!approved) {
|
|
192
|
+
unapproved.push(id);
|
|
193
|
+
} else if (!has_heading) {
|
|
194
|
+
empty.push(id);
|
|
195
|
+
}
|
|
114
196
|
} else {
|
|
115
197
|
missing.push(id);
|
|
116
198
|
}
|
|
117
199
|
}
|
|
118
200
|
|
|
119
|
-
return { missing, present };
|
|
201
|
+
return { missing, present, unapproved, empty };
|
|
120
202
|
}
|
|
121
203
|
|
|
122
204
|
function main() {
|
|
@@ -125,21 +207,36 @@ function main() {
|
|
|
125
207
|
|
|
126
208
|
const sprintFilePath = path.resolve(args[0]);
|
|
127
209
|
|
|
128
|
-
|
|
210
|
+
// Allow test-isolation override of execution_mode
|
|
211
|
+
const execMode = process.env.CLEARGATE_EXEC_MODE ?? 'v2';
|
|
212
|
+
|
|
213
|
+
const { missing, present, unapproved, empty } = assertWorkItemFiles(sprintFilePath, REPO_ROOT);
|
|
129
214
|
|
|
130
|
-
|
|
215
|
+
const hasProblems = missing.length > 0 || unapproved.length > 0 || empty.length > 0;
|
|
216
|
+
|
|
217
|
+
if (!hasProblems) {
|
|
131
218
|
process.stdout.write(
|
|
132
|
-
`OK: all ${present.length}
|
|
219
|
+
`OK: all ${present.length} work-item file(s) present, approved, and non-empty in pending-sync/ or archive/\n`
|
|
133
220
|
);
|
|
134
221
|
process.exit(0);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
process.stderr.write(
|
|
140
|
-
|
|
141
|
-
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Build structured stderr output
|
|
225
|
+
if (missing.length > 0) {
|
|
226
|
+
process.stderr.write(`MISSING (${missing.length}): ${missing.join(', ')}\n`);
|
|
227
|
+
}
|
|
228
|
+
if (unapproved.length > 0) {
|
|
229
|
+
process.stderr.write(`UNAPPROVED (${unapproved.length}): ${unapproved.join(', ')}\n`);
|
|
230
|
+
}
|
|
231
|
+
if (empty.length > 0) {
|
|
232
|
+
process.stderr.write(`STUB-EMPTY (${empty.length}): ${empty.join(', ')}\n`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (execMode === 'v2') {
|
|
142
236
|
process.exit(1);
|
|
237
|
+
} else {
|
|
238
|
+
// v1: warn only, exit 0
|
|
239
|
+
process.exit(0);
|
|
143
240
|
}
|
|
144
241
|
}
|
|
145
242
|
|
|
@@ -32,6 +32,29 @@ import { execSync } from 'node:child_process';
|
|
|
32
32
|
import { TERMINAL_STATES } from './constants.mjs';
|
|
33
33
|
import { validateState } from './validate_state.mjs';
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Migrate a v1 state.json to v2 by injecting lane fields with defaults.
|
|
37
|
+
* Inlined from update_state.mjs:migrateV1ToV2 to avoid triggering that
|
|
38
|
+
* script's CLI main() on import (update_state.mjs has no module guard).
|
|
39
|
+
* @param {object} state - Parsed v1 state object
|
|
40
|
+
* @returns {object} - The mutated (now v2) state object
|
|
41
|
+
*/
|
|
42
|
+
function migrateV1ToV2(state) {
|
|
43
|
+
state.schema_version = 2;
|
|
44
|
+
const storyIds = Object.keys(state.stories || {});
|
|
45
|
+
for (const id of storyIds) {
|
|
46
|
+
const story = state.stories[id];
|
|
47
|
+
if (story.lane == null) story.lane = 'standard';
|
|
48
|
+
if (story.lane_assigned_by == null) story.lane_assigned_by = 'migration-default';
|
|
49
|
+
if (story.lane_demoted_at === undefined) story.lane_demoted_at = null;
|
|
50
|
+
if (story.lane_demotion_reason === undefined) story.lane_demotion_reason = null;
|
|
51
|
+
}
|
|
52
|
+
process.stderr.write(
|
|
53
|
+
`migration: schema_version 1 → 2 for sprint ${state.sprint_id} (${storyIds.length} stories defaulted to lane: standard)\n`
|
|
54
|
+
);
|
|
55
|
+
return state;
|
|
56
|
+
}
|
|
57
|
+
|
|
35
58
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
36
59
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
37
60
|
const SCRIPTS_DIR = __dirname;
|
|
@@ -129,6 +152,12 @@ function main() {
|
|
|
129
152
|
process.exit(1);
|
|
130
153
|
}
|
|
131
154
|
|
|
155
|
+
// Migrate v1 → v2 if needed before strict validation
|
|
156
|
+
if (state.schema_version === 1) {
|
|
157
|
+
state = migrateV1ToV2(state);
|
|
158
|
+
atomicWrite(stateFile, state);
|
|
159
|
+
}
|
|
160
|
+
|
|
132
161
|
const { valid, errors } = validateState(state);
|
|
133
162
|
if (!valid) {
|
|
134
163
|
process.stderr.write('Error: state.json validation failed:\n');
|
|
@@ -154,6 +183,70 @@ function main() {
|
|
|
154
183
|
|
|
155
184
|
process.stdout.write(`Step 1-2 passed: all ${Object.keys(state.stories || {}).length} stories are terminal.\n`);
|
|
156
185
|
|
|
186
|
+
// ── Step 2.5: v2.1 validation — activation-gated ──────────────────────────
|
|
187
|
+
// Activation gate: schema_version >= 2 AND at least one story has lane: 'fast'
|
|
188
|
+
const isV2 = (state.schema_version || 1) >= 2;
|
|
189
|
+
const hasFastLane = isV2 && Object.values(state.stories || {}).some(
|
|
190
|
+
(s) => /** @type {any} */ (s).lane === 'fast'
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
if (isV2 && hasFastLane) {
|
|
194
|
+
// Naming convention: sprint dir must match ^SPRINT-\d{2,3}$
|
|
195
|
+
const sprintDirName = path.basename(sprintDir);
|
|
196
|
+
if (!/^SPRINT-\d{2,3}$/.test(sprintDirName)) {
|
|
197
|
+
process.stderr.write(
|
|
198
|
+
`close_sprint: sprint dir "${sprintDirName}" does not match ^SPRINT-\\d{2,3}$\n` +
|
|
199
|
+
` Expected format: SPRINT-NN or SPRINT-NNN (e.g. SPRINT-14)\n` +
|
|
200
|
+
` Got: "${sprintDirName}" at path: ${sprintDir}\n`
|
|
201
|
+
);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Read REPORT.md
|
|
206
|
+
const reportFile2 = path.join(sprintDir, 'REPORT.md');
|
|
207
|
+
if (!fs.existsSync(reportFile2)) {
|
|
208
|
+
process.stderr.write(
|
|
209
|
+
`close_sprint: v2.1 validation requires REPORT.md at ${reportFile2}\n` +
|
|
210
|
+
' Run the Reporter agent first, then re-run close_sprint.mjs.\n'
|
|
211
|
+
);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
const report = fs.readFileSync(reportFile2, 'utf8');
|
|
215
|
+
|
|
216
|
+
// Check required §3 metric rows
|
|
217
|
+
const requiredMetricRows = [
|
|
218
|
+
/Fast-Track Ratio/,
|
|
219
|
+
/Fast-Track Demotion Rate/,
|
|
220
|
+
/Hotfix Count/,
|
|
221
|
+
/Hotfix-to-Story Ratio/,
|
|
222
|
+
/Hotfix Cap Breaches/,
|
|
223
|
+
/LD events/,
|
|
224
|
+
];
|
|
225
|
+
const missingMetrics = requiredMetricRows.filter((rx) => !rx.test(report));
|
|
226
|
+
if (missingMetrics.length > 0) {
|
|
227
|
+
process.stderr.write(
|
|
228
|
+
`close_sprint: §3 missing rows: ${missingMetrics.map((rx) => rx.source).join(', ')}\n`
|
|
229
|
+
);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Check required §5 sections
|
|
234
|
+
const requiredSections = [
|
|
235
|
+
/Lane Audit/,
|
|
236
|
+
/Hotfix Audit/,
|
|
237
|
+
/Hotfix Trend/,
|
|
238
|
+
];
|
|
239
|
+
const missingSections = requiredSections.filter((rx) => !rx.test(report));
|
|
240
|
+
if (missingSections.length > 0) {
|
|
241
|
+
process.stderr.write(
|
|
242
|
+
`close_sprint: §5 missing: ${missingSections.map((rx) => rx.source).join(', ')}\n`
|
|
243
|
+
);
|
|
244
|
+
process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
process.stdout.write('Step 2.5 passed: v2.1 validation — all required §3 metrics and §5 sections present.\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
157
250
|
// ── Step 3: Invoke prefill_report.mjs ─────────────────────────────────────
|
|
158
251
|
process.stdout.write('Step 3: running prefill_report.mjs...\n');
|
|
159
252
|
try {
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ClearGate Execution Phase v2 — Constants
|
|
3
3
|
*
|
|
4
|
-
* state.json
|
|
4
|
+
* state.json v2 Schema (LOCKED — any future field change must bump schema_version):
|
|
5
5
|
*
|
|
6
6
|
* {
|
|
7
|
-
* "schema_version":
|
|
7
|
+
* "schema_version": 2, // integer, mandatory
|
|
8
8
|
* "sprint_id": "S-NN", // string
|
|
9
9
|
* "execution_mode": "v1"|"v2", // string
|
|
10
10
|
* "sprint_status": "Active", // string
|
|
@@ -15,7 +15,11 @@
|
|
|
15
15
|
* "arch_bounces": 0, // integer 0..BOUNCE_CAP
|
|
16
16
|
* "worktree": null, // string|null — path to worktree checkout
|
|
17
17
|
* "updated_at": "<ISO-8601>", // string
|
|
18
|
-
* "notes": ""
|
|
18
|
+
* "notes": "", // string
|
|
19
|
+
* "lane": "standard", // additive v2; default "standard"
|
|
20
|
+
* "lane_assigned_by": "architect" | "human-override" | "migration-default",
|
|
21
|
+
* "lane_demoted_at": "<ISO-8601>" | null,
|
|
22
|
+
* "lane_demotion_reason": string | null
|
|
19
23
|
* }
|
|
20
24
|
* },
|
|
21
25
|
* "last_action": "<string>", // human-readable last operation
|
|
@@ -23,7 +27,7 @@
|
|
|
23
27
|
* }
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
|
-
export const SCHEMA_VERSION =
|
|
30
|
+
export const SCHEMA_VERSION = 2;
|
|
27
31
|
|
|
28
32
|
export const BOUNCE_CAP = 3;
|
|
29
33
|
|
|
@@ -139,8 +139,12 @@ function main() {
|
|
|
139
139
|
if (executionMode === 'v2') {
|
|
140
140
|
// Hard block: do not create state.json
|
|
141
141
|
process.stderr.write(stderr);
|
|
142
|
+
// Count categories from structured stderr lines
|
|
143
|
+
const missingCount = (stderr.match(/^MISSING \((\d+)\):/m) ?? [])[1] ?? '0';
|
|
144
|
+
const unapprovedCount = (stderr.match(/^UNAPPROVED \((\d+)\):/m) ?? [])[1] ?? '0';
|
|
145
|
+
const emptyCount = (stderr.match(/^STUB-EMPTY \((\d+)\):/m) ?? [])[1] ?? '0';
|
|
142
146
|
process.stderr.write(
|
|
143
|
-
`ERROR: v2 sprint init blocked —
|
|
147
|
+
`ERROR: v2 sprint init blocked — ${missingCount} items missing, ${unapprovedCount} unapproved, ${emptyCount} stub-empty. Fix the above, then re-run init.\n`
|
|
144
148
|
);
|
|
145
149
|
process.exit(1);
|
|
146
150
|
} else {
|
|
@@ -162,6 +166,10 @@ function main() {
|
|
|
162
166
|
worktree: null,
|
|
163
167
|
updated_at: now,
|
|
164
168
|
notes: '',
|
|
169
|
+
lane: 'standard',
|
|
170
|
+
lane_assigned_by: 'migration-default',
|
|
171
|
+
lane_demoted_at: null,
|
|
172
|
+
lane_demotion_reason: null,
|
|
165
173
|
};
|
|
166
174
|
}
|
|
167
175
|
|
|
@@ -104,6 +104,80 @@ detect_stack() {
|
|
|
104
104
|
fi
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# resolve_story_id_from_branch <branch>
|
|
109
|
+
# Extracts story/CR/bug ID from a branch name like story/STORY-022-04.
|
|
110
|
+
# Pattern (per M4 plan §1): (story|cr|bug)/([A-Z-]+-[0-9]+(-[0-9]+)?)
|
|
111
|
+
# Returns the ID (group 2) on stdout, or empty string on no match.
|
|
112
|
+
# Cross-OS: bash 3.2+, POSIX ERE grep -E, quoted expansions, no sed -E extensions.
|
|
113
|
+
# ---------------------------------------------------------------------------
|
|
114
|
+
resolve_story_id_from_branch() {
|
|
115
|
+
local branch="$1"
|
|
116
|
+
# Match the full pattern and extract just the ID part after the slash
|
|
117
|
+
local matched
|
|
118
|
+
matched="$(printf '%s\n' "${branch}" | grep -oE '(story|cr|bug)/[A-Z][A-Z-]*[A-Z]-[0-9]+(-[0-9]+)?' | head -1 || true)"
|
|
119
|
+
if [[ -n "${matched}" ]]; then
|
|
120
|
+
# Strip the (story|cr|bug)/ prefix — everything up to and including first /
|
|
121
|
+
printf '%s\n' "${matched#*/}"
|
|
122
|
+
else
|
|
123
|
+
printf ''
|
|
124
|
+
fi
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
# resolve_lane <state_json_path> <story_id>
|
|
129
|
+
# Reads the lane field for a story from state.json.
|
|
130
|
+
# Returns "standard" if story absent, lane absent, or jq fails.
|
|
131
|
+
# jq 1.5+ syntax. Cross-OS portable.
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
resolve_lane() {
|
|
134
|
+
local state_json="$1"
|
|
135
|
+
local story_id="$2"
|
|
136
|
+
if [[ ! -f "${state_json}" ]]; then
|
|
137
|
+
printf 'standard\n'
|
|
138
|
+
return
|
|
139
|
+
fi
|
|
140
|
+
local lane
|
|
141
|
+
lane="$(jq -r --arg sid "${story_id}" '.stories[$sid].lane // "standard"' "${state_json}" 2>/dev/null || printf 'standard')"
|
|
142
|
+
if [[ -z "${lane}" || "${lane}" = "null" ]]; then
|
|
143
|
+
lane="standard"
|
|
144
|
+
fi
|
|
145
|
+
printf '%s\n' "${lane}"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# append_ld_event <sprint_md_path> <story_id> <reason>
|
|
150
|
+
# Appends an LD event row to the sprint markdown §4 Events section.
|
|
151
|
+
# If "## 4. Events" heading absent, creates it with table header first.
|
|
152
|
+
# Idempotent by design (orchestrator calls once per demotion).
|
|
153
|
+
# Cross-OS: bash 3.2+, portable date, printf.
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
append_ld_event() {
|
|
156
|
+
local sprint_md="$1"
|
|
157
|
+
local story_id="$2"
|
|
158
|
+
local reason="$3"
|
|
159
|
+
local timestamp
|
|
160
|
+
timestamp="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
161
|
+
# Truncate reason to 80 chars
|
|
162
|
+
reason="$(printf '%s' "${reason}" | cut -c1-80)"
|
|
163
|
+
|
|
164
|
+
if [[ ! -f "${sprint_md}" ]]; then
|
|
165
|
+
printf 'append_ld_event: sprint markdown not found: %s\n' "${sprint_md}" >&2
|
|
166
|
+
return 1
|
|
167
|
+
fi
|
|
168
|
+
|
|
169
|
+
local row
|
|
170
|
+
row="$(printf '| LD | %s | %s | %s |' "${story_id}" "${timestamp}" "${reason}")"
|
|
171
|
+
|
|
172
|
+
if grep -q '^## 4\. Events' "${sprint_md}" 2>/dev/null; then
|
|
173
|
+
# Section exists: append row only
|
|
174
|
+
printf '\n%s\n' "${row}" >> "${sprint_md}"
|
|
175
|
+
else
|
|
176
|
+
# Section absent: append heading + table header + row
|
|
177
|
+
printf '\n## 4. Events\n\n| Event | Story | Timestamp | Reason |\n|---|---|---|---|\n%s\n' "${row}" >> "${sprint_md}"
|
|
178
|
+
fi
|
|
179
|
+
}
|
|
180
|
+
|
|
107
181
|
# ---------------------------------------------------------------------------
|
|
108
182
|
# diff_package_json <worktree_path> <branch>
|
|
109
183
|
# Prints new runtime deps (non-dev) introduced vs <branch>^.
|
|
@@ -304,4 +304,68 @@ esac
|
|
|
304
304
|
|
|
305
305
|
cat "$REPORT_FILE" >&2
|
|
306
306
|
|
|
307
|
-
|
|
307
|
+
# ---------------------------------------------------------------------------
|
|
308
|
+
# Lane-aware post-scan routing (STORY-022-04)
|
|
309
|
+
# Resolve REPO_ROOT from SCRIPT_DIR (scripts/ lives two levels below repo root)
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
REPO_ROOT_FOR_LANE="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
|
312
|
+
ACTIVE_FILE="${REPO_ROOT_FOR_LANE}/.cleargate/sprint-runs/.active"
|
|
313
|
+
|
|
314
|
+
STORY_ID=""
|
|
315
|
+
if [[ -n "${BRANCH:-}" ]]; then
|
|
316
|
+
STORY_ID="$(resolve_story_id_from_branch "${BRANCH}")"
|
|
317
|
+
fi
|
|
318
|
+
|
|
319
|
+
STATE_JSON=""
|
|
320
|
+
SPRINT_ID=""
|
|
321
|
+
SPRINT_MD=""
|
|
322
|
+
if [[ -f "${ACTIVE_FILE}" ]]; then
|
|
323
|
+
SPRINT_ID="$(cat "${ACTIVE_FILE}" | tr -d '[:space:]')"
|
|
324
|
+
STATE_JSON="${REPO_ROOT_FOR_LANE}/.cleargate/sprint-runs/${SPRINT_ID}/state.json"
|
|
325
|
+
# Sprint markdown lives in delivery/pending-sync/
|
|
326
|
+
SPRINT_MD="$(ls "${REPO_ROOT_FOR_LANE}/.cleargate/delivery/pending-sync/${SPRINT_ID}_"*.md 2>/dev/null | head -1 || true)"
|
|
327
|
+
fi
|
|
328
|
+
|
|
329
|
+
LANE="standard"
|
|
330
|
+
if [[ -n "${STORY_ID}" && -n "${STATE_JSON}" ]]; then
|
|
331
|
+
LANE="$(resolve_lane "${STATE_JSON}" "${STORY_ID}")"
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
if [[ "${LANE}" = "fast" ]]; then
|
|
335
|
+
if [[ "${OVERALL_EXIT}" -eq 0 ]]; then
|
|
336
|
+
# Fast lane + scanner pass: skip QA spawn signal
|
|
337
|
+
printf 'pre-gate: lane=fast -> skipping QA spawn for %s\n' "${STORY_ID}"
|
|
338
|
+
if [[ -n "${STATE_JSON}" ]]; then
|
|
339
|
+
# Positional invocation: node update_state.mjs <STORY-ID> <new-state>
|
|
340
|
+
CLEARGATE_STATE_FILE="${STATE_JSON}" \
|
|
341
|
+
node "${SCRIPT_DIR}/update_state.mjs" "${STORY_ID}" "Architect Passed" \
|
|
342
|
+
> /dev/null 2>&1 || true
|
|
343
|
+
fi
|
|
344
|
+
exit 0
|
|
345
|
+
else
|
|
346
|
+
# Fast lane + scanner fail: auto-demote + LD event
|
|
347
|
+
SCANNER_FAIL_REASON="pre-gate scanner failed (exit ${OVERALL_EXIT})"
|
|
348
|
+
if [[ -n "${STATE_JSON}" ]]; then
|
|
349
|
+
# Positional invocation: node update_state.mjs <STORY-ID> --lane-demote <reason>
|
|
350
|
+
# Capture exit independently per FLASHCARD #hooks #bash #exit-capture 2026-04-26
|
|
351
|
+
_tmp_demote_out="$(mktemp)"
|
|
352
|
+
CLEARGATE_STATE_FILE="${STATE_JSON}" \
|
|
353
|
+
node "${SCRIPT_DIR}/update_state.mjs" "${STORY_ID}" --lane-demote \
|
|
354
|
+
"${SCANNER_FAIL_REASON}" > "${_tmp_demote_out}" 2>&1
|
|
355
|
+
_demote_exit=$?
|
|
356
|
+
rm -f "${_tmp_demote_out}"
|
|
357
|
+
if [[ ${_demote_exit} -ne 0 ]]; then
|
|
358
|
+
printf 'pre-gate: warn: lane-demote failed (exit %s) for %s\n' \
|
|
359
|
+
"${_demote_exit}" "${STORY_ID}" >&2
|
|
360
|
+
fi
|
|
361
|
+
fi
|
|
362
|
+
if [[ -n "${SPRINT_MD}" ]]; then
|
|
363
|
+
append_ld_event "${SPRINT_MD}" "${STORY_ID}" "${SCANNER_FAIL_REASON}"
|
|
364
|
+
fi
|
|
365
|
+
# Exit with the original scanner exit code so orchestrator routes to QA
|
|
366
|
+
exit "${OVERALL_EXIT}"
|
|
367
|
+
fi
|
|
368
|
+
fi
|
|
369
|
+
|
|
370
|
+
# lane=standard (or unknown/missing): existing behaviour
|
|
371
|
+
exit "${OVERALL_EXIT}"
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"$id": "https://cleargate.dev/schemas/state.
|
|
4
|
-
"title": "ClearGate Sprint State
|
|
5
|
-
"description": "Schema for .cleargate/sprint-runs/<sprint-id>/state.json. schema_version=
|
|
3
|
+
"$id": "https://cleargate.dev/schemas/state.v2.json",
|
|
4
|
+
"title": "ClearGate Sprint State v2",
|
|
5
|
+
"description": "Schema for .cleargate/sprint-runs/<sprint-id>/state.json. schema_version=2 adds per-story lane fields (lane, lane_assigned_by, lane_demoted_at, lane_demotion_reason). Migrated from v1 on first read by update_state.mjs.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"required": [
|
|
8
8
|
"schema_version",
|
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"last_action",
|
|
14
14
|
"updated_at"
|
|
15
15
|
],
|
|
16
|
-
"additionalProperties":
|
|
16
|
+
"additionalProperties": true,
|
|
17
17
|
"properties": {
|
|
18
18
|
"schema_version": {
|
|
19
19
|
"type": "integer",
|
|
20
|
-
"const":
|
|
21
|
-
"description": "Schema version. Must be
|
|
20
|
+
"const": 2,
|
|
21
|
+
"description": "Schema version. Must be 2 for v2 state files. Bumped from v1 by migration in update_state.mjs."
|
|
22
22
|
},
|
|
23
23
|
"sprint_id": {
|
|
24
24
|
"type": "string",
|
|
@@ -61,9 +61,13 @@
|
|
|
61
61
|
"arch_bounces",
|
|
62
62
|
"worktree",
|
|
63
63
|
"updated_at",
|
|
64
|
-
"notes"
|
|
64
|
+
"notes",
|
|
65
|
+
"lane",
|
|
66
|
+
"lane_assigned_by",
|
|
67
|
+
"lane_demoted_at",
|
|
68
|
+
"lane_demotion_reason"
|
|
65
69
|
],
|
|
66
|
-
"additionalProperties":
|
|
70
|
+
"additionalProperties": true,
|
|
67
71
|
"properties": {
|
|
68
72
|
"state": {
|
|
69
73
|
"type": "string",
|
|
@@ -103,6 +107,25 @@
|
|
|
103
107
|
"notes": {
|
|
104
108
|
"type": "string",
|
|
105
109
|
"description": "Free-form notes for this story (escalation reason, QA comments, etc.)"
|
|
110
|
+
},
|
|
111
|
+
"lane": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"enum": ["standard", "fast"],
|
|
114
|
+
"description": "Execution lane for this story. Default 'standard'. Set by Architect at sprint design or via --lane flag."
|
|
115
|
+
},
|
|
116
|
+
"lane_assigned_by": {
|
|
117
|
+
"type": "string",
|
|
118
|
+
"enum": ["architect", "human-override", "migration-default"],
|
|
119
|
+
"description": "Who assigned the lane value. 'migration-default' for stories migrated from v1."
|
|
120
|
+
},
|
|
121
|
+
"lane_demoted_at": {
|
|
122
|
+
"type": ["string", "null"],
|
|
123
|
+
"format": "date-time",
|
|
124
|
+
"description": "ISO-8601 timestamp when the story was demoted from fast to standard, or null if never demoted."
|
|
125
|
+
},
|
|
126
|
+
"lane_demotion_reason": {
|
|
127
|
+
"type": ["string", "null"],
|
|
128
|
+
"description": "Human-readable reason for lane demotion, or null if never demoted."
|
|
106
129
|
}
|
|
107
130
|
}
|
|
108
131
|
}
|