cleargate 0.5.0 → 0.6.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 (54) hide show
  1. package/dist/MANIFEST.json +30 -16
  2. package/dist/cli.cjs +485 -51
  3. package/dist/cli.cjs.map +1 -1
  4. package/dist/cli.js +480 -47
  5. package/dist/cli.js.map +1 -1
  6. package/dist/templates/cleargate-planning/.claude/agents/architect.md +24 -0
  7. package/dist/templates/cleargate-planning/.claude/agents/developer.md +24 -0
  8. package/dist/templates/cleargate-planning/.claude/agents/reporter.md +74 -0
  9. package/dist/templates/cleargate-planning/.claude/hooks/pre-edit-gate.sh +162 -0
  10. package/dist/templates/cleargate-planning/.claude/hooks/session-start.sh +10 -7
  11. package/dist/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +9 -8
  12. package/dist/templates/cleargate-planning/.claude/hooks/token-ledger.sh +36 -13
  13. package/dist/templates/cleargate-planning/.claude/settings.json +9 -0
  14. package/dist/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +55 -0
  15. package/dist/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +7 -7
  16. package/dist/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +137 -40
  17. package/dist/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +93 -0
  18. package/dist/templates/cleargate-planning/.cleargate/scripts/constants.mjs +8 -4
  19. package/dist/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +9 -1
  20. package/dist/templates/cleargate-planning/.cleargate/scripts/pre_gate_common.sh +74 -0
  21. package/dist/templates/cleargate-planning/.cleargate/scripts/pre_gate_runner.sh +65 -1
  22. package/dist/templates/cleargate-planning/.cleargate/scripts/state.schema.json +31 -8
  23. package/dist/templates/cleargate-planning/.cleargate/scripts/update_state.mjs +93 -8
  24. package/dist/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +19 -4
  25. package/dist/templates/cleargate-planning/.cleargate/templates/hotfix.md +58 -0
  26. package/dist/templates/cleargate-planning/.cleargate/templates/sprint_report.md +32 -2
  27. package/dist/templates/cleargate-planning/.cleargate/templates/story.md +3 -1
  28. package/dist/templates/cleargate-planning/CLAUDE.md +1 -1
  29. package/dist/templates/cleargate-planning/MANIFEST.json +30 -16
  30. package/package.json +1 -1
  31. package/templates/cleargate-planning/.claude/agents/architect.md +24 -0
  32. package/templates/cleargate-planning/.claude/agents/developer.md +24 -0
  33. package/templates/cleargate-planning/.claude/agents/reporter.md +74 -0
  34. package/templates/cleargate-planning/.claude/hooks/pre-edit-gate.sh +162 -0
  35. package/templates/cleargate-planning/.claude/hooks/session-start.sh +10 -7
  36. package/templates/cleargate-planning/.claude/hooks/stamp-and-gate.sh +9 -8
  37. package/templates/cleargate-planning/.claude/hooks/token-ledger.sh +36 -13
  38. package/templates/cleargate-planning/.claude/settings.json +9 -0
  39. package/templates/cleargate-planning/.cleargate/knowledge/cleargate-protocol.md +55 -0
  40. package/templates/cleargate-planning/.cleargate/knowledge/readiness-gates.md +7 -7
  41. package/templates/cleargate-planning/.cleargate/scripts/assert_story_files.mjs +137 -40
  42. package/templates/cleargate-planning/.cleargate/scripts/close_sprint.mjs +93 -0
  43. package/templates/cleargate-planning/.cleargate/scripts/constants.mjs +8 -4
  44. package/templates/cleargate-planning/.cleargate/scripts/init_sprint.mjs +9 -1
  45. package/templates/cleargate-planning/.cleargate/scripts/pre_gate_common.sh +74 -0
  46. package/templates/cleargate-planning/.cleargate/scripts/pre_gate_runner.sh +65 -1
  47. package/templates/cleargate-planning/.cleargate/scripts/state.schema.json +31 -8
  48. package/templates/cleargate-planning/.cleargate/scripts/update_state.mjs +93 -8
  49. package/templates/cleargate-planning/.cleargate/templates/Sprint Plan Template.md +19 -4
  50. package/templates/cleargate-planning/.cleargate/templates/hotfix.md +58 -0
  51. package/templates/cleargate-planning/.cleargate/templates/sprint_report.md +32 -2
  52. package/templates/cleargate-planning/.cleargate/templates/story.md +3 -1
  53. package/templates/cleargate-planning/CLAUDE.md +1 -1
  54. 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 story-file existence assertion
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
- * STORY-\d+-\d+ IDs, then checks that each has a corresponding
9
- * pending-sync/STORY-<id>_*.md file under the repo root.
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 story files present (prints summary to stdout)
12
- * Exit 1: one or more missing (prints JSON {missing,present} to stderr)
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 STORY-\d+-\d+ IDs from a text block.
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 extractStoryIds(text) {
57
- const matches = text.match(/STORY-\d+-\d+/g) || [];
58
- return [...new Set(matches)];
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 STORY-<id>_*.md
63
- * Returns the matching filename or null.
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 findStoryFile(repoRoot, storyId) {
66
- const pendingSync = path.join(repoRoot, '.cleargate', 'delivery', 'pending-sync');
67
- let entries;
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
- entries = fs.readdirSync(pendingSync);
123
+ content = fs.readFileSync(filePath, 'utf8');
70
124
  } catch {
71
- return null;
125
+ return { approved: false, has_heading: false };
72
126
  }
73
- const prefix = `${storyId}_`;
74
- const match = entries.find(
75
- (e) => e.startsWith(prefix) && e.endsWith('.md')
76
- );
77
- return match ? path.join(pendingSync, match) : null;
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 assertStoryFiles(sprintFilePath, repoRoot) {
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 storyIds = extractStoryIds(section);
102
- if (storyIds.length === 0) {
103
- process.stderr.write('Warning: no STORY-IDs found in §1 Consolidated Deliverables\n');
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
- for (const id of storyIds) {
111
- const found = findStoryFile(repoRoot, id);
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
- const { missing, present } = assertStoryFiles(sprintFilePath, REPO_ROOT);
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
- if (missing.length === 0) {
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} story file(s) present in pending-sync/\n`
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
- } else {
136
- process.stderr.write(
137
- JSON.stringify({ missing, present }, null, 2) + '\n'
138
- );
139
- process.stderr.write(
140
- `MISSING: ${missing.length} story file(s) not found in pending-sync/: ${missing.join(', ')}\n`
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 v1 Schema (LOCKED — any future field change must bump schema_version):
4
+ * state.json v2 Schema (LOCKED — any future field change must bump schema_version):
5
5
  *
6
6
  * {
7
- * "schema_version": 1, // integer, mandatory
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": "" // string
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 = 1;
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 — story files missing. Fix the above, then re-run init.\n`
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
- exit $OVERALL_EXIT
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.v1.json",
4
- "title": "ClearGate Sprint State v1",
5
- "description": "Schema for .cleargate/sprint-runs/<sprint-id>/state.json. schema_version=1 is LOCKED any field addition or removal must bump 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": false,
16
+ "additionalProperties": true,
17
17
  "properties": {
18
18
  "schema_version": {
19
19
  "type": "integer",
20
- "const": 1,
21
- "description": "Schema version. Must be 1 for v1 state files. Bump on any schema change."
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": false,
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
  }