@tekyzinc/gsd-t 4.9.14 → 4.10.10

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 (51) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +1 -1
  3. package/bin/gsd-t-competition-judge.cjs +7 -1
  4. package/bin/gsd-t-context-brief-kinds/impact.cjs +91 -4
  5. package/bin/gsd-t-context-brief-kinds/partition.cjs +95 -0
  6. package/bin/gsd-t-context-brief-kinds/plan.cjs +110 -4
  7. package/bin/gsd-t-file-disjointness.cjs +319 -7
  8. package/bin/gsd-t-graph-anti-grep-lint.cjs +515 -0
  9. package/bin/gsd-t-graph-edge-extract.cjs +612 -0
  10. package/bin/gsd-t-graph-freshness.cjs +506 -0
  11. package/bin/gsd-t-graph-index.cjs +540 -0
  12. package/bin/gsd-t-graph-k1-sqlite-stream.cjs +251 -0
  13. package/bin/gsd-t-graph-query-cli.cjs +1182 -0
  14. package/bin/gsd-t-graph-scip-upgrade.cjs +440 -0
  15. package/bin/gsd-t-graph-store-bakeoff.cjs +889 -0
  16. package/bin/gsd-t-graph-synthetic-gen.cjs +304 -0
  17. package/bin/gsd-t-graph-ts-throughput.cjs +587 -0
  18. package/bin/gsd-t-scip-reader.cjs +167 -0
  19. package/bin/gsd-t.js +166 -48
  20. package/commands/gsd-t-debug.md +10 -0
  21. package/commands/gsd-t-design-build.md +10 -0
  22. package/commands/gsd-t-execute.md +15 -0
  23. package/commands/gsd-t-feature.md +15 -7
  24. package/commands/gsd-t-gap-analysis.md +17 -7
  25. package/commands/gsd-t-impact.md +25 -0
  26. package/commands/gsd-t-integrate.md +25 -0
  27. package/commands/gsd-t-partition.md +18 -0
  28. package/commands/gsd-t-plan.md +16 -0
  29. package/commands/gsd-t-populate.md +16 -5
  30. package/commands/gsd-t-prd.md +18 -0
  31. package/commands/gsd-t-project.md +19 -0
  32. package/commands/gsd-t-promote-debt.md +16 -5
  33. package/commands/gsd-t-qa.md +20 -8
  34. package/commands/gsd-t-quick.md +10 -0
  35. package/commands/gsd-t-scan.md +21 -3
  36. package/commands/gsd-t-test-sync.md +10 -0
  37. package/commands/gsd-t-verify.md +25 -0
  38. package/commands/gsd-t-wave.md +8 -0
  39. package/package.json +10 -2
  40. package/templates/workflows/gsd-t-debug.workflow.js +81 -0
  41. package/templates/workflows/gsd-t-integrate.workflow.js +47 -1
  42. package/templates/workflows/gsd-t-phase.workflow.js +84 -0
  43. package/templates/workflows/gsd-t-quick.workflow.js +64 -0
  44. package/templates/workflows/gsd-t-scan.workflow.js +200 -9
  45. package/templates/workflows/gsd-t-verify.workflow.js +50 -1
  46. package/bin/graph-cgc.js +0 -510
  47. package/bin/graph-indexer.js +0 -147
  48. package/bin/graph-overlay.js +0 -195
  49. package/bin/graph-parsers.js +0 -327
  50. package/bin/graph-query.js +0 -453
  51. package/bin/graph-store.js +0 -154
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to GSD-T are documented here. Updated with each release.
4
4
 
5
+ ## [4.10.10] - 2026-06-26
6
+
7
+ ### Added — M94 persistent code graph + M95 real SCIP call-graph resolution; graph runtime now propagates into projects
8
+
9
+ The code graph is no longer grep-dependent in principle: M94 shipped a persistent, all-local on-disk index (files / functions / imports / call graph) with a deterministic no-grep-fallback query CLI, and M95 made the precise tier REAL — it reads scip-typescript's `index.scip` and resolves cross-file call edges instead of relabeling unresolved tree-sitter edges "compiler-accurate." Verified on the real Atos repo: the test→impl verb returns 164 resolved cross-file edges (was 0). The graph runtime is now on the project-propagation list, so `update-all` copies the query CLI into every project's `bin/` — the wired consumers (execute/wave disjointness, debug, quick, impact, plan, scan) can finally read a project's graph instead of falling back to grep.
10
+
11
+ - `bin/gsd-t-scip-reader.cjs`: NEW — decodes `index.scip` into symbol→funcId + per-file reference maps (via scip-typescript's bundled protobuf decoder, located across install layouts, fail-loud if absent).
12
+ - `bin/gsd-t-graph-scip-upgrade.cjs`: `buildScipResolver` runs scip-typescript ONCE per repo (was once-per-file with the output discarded) and resolves `UNRESOLVED#<name>` call edges; tier honesty — `compiler-accurate` only when SCIP actually resolved an edge.
13
+ - `bin/gsd-t-graph-index.cjs`: auto-builds the resolver and threads it through `parse_and_put`.
14
+ - `bin/gsd-t.js`: `gsd-t install` auto-installs scip-typescript + scip-python; `gsd-t doctor` reports their presence; graph runtime added to `PROJECT_BIN_TOOLS`.
15
+ - `bin/gsd-t-graph-freshness.cjs` + `bin/gsd-t-graph-query-cli.cjs`: OOM fix (`runFreshnessCheck` walked `/` → 17 GB) + `openDb` canonical store-path fix + parse_and_put signature fix.
16
+ - `test/m95-scip-call-resolution.test.js`: NEW — resolution, tier honesty, fail-loud, passthrough (5/5).
17
+
18
+ Decision: GSD-T's zero-dependency installer rule is reframed as a GUIDING PRINCIPLE, not a hard rule — SCIP indexers are now an install requirement because they materially improve the product. Suite: 2502/2502 pass (4 pre-existing skips). Both heavy real-Atos tests pass.
19
+
5
20
  ## [4.9.14] - 2026-06-25 (workflow runCli retry — reliability patch)
6
21
 
7
22
  ### Fixed — transient helper-call flakes no longer false-block workflows
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # GSD-T: Contract-Driven Development for Claude Code
2
2
 
3
- **v4.9.14** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
3
+ **v4.10.10** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
4
4
 
5
5
  **Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
6
6
  **Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
@@ -123,8 +123,14 @@ function scorePartition(candidate, projectDir) {
123
123
 
124
124
  // Run the real oracle when available; otherwise fall back to a self-contained
125
125
  // overlap check so the judge still works if the lib isn't co-located.
126
+ // fallbackToTouchesOnly: the judge SCORES partition shapes — it is not gating a
127
+ // live parallel fan-out — so when the graph index is absent (M94: the query CLI
128
+ // exists but no index is built, e.g. on a tiny synthetic candidate), it must
129
+ // degrade to the literal-Touches disjointness count, NOT return empty groups
130
+ // (which would zero parallelGroups and break scoring). The FAIL-LOUD-HALT path
131
+ // is reserved for execute's real fan-out gate, which requires the graph.
126
132
  const res = proveDisjointness
127
- ? proveDisjointness({ tasks, projectDir })
133
+ ? proveDisjointness({ tasks, projectDir, disjointnessFallback: "touches-only" })
128
134
  : _localDisjoint(tasks);
129
135
 
130
136
  const parallelGroups = (res.parallel || []).length;
@@ -6,6 +6,11 @@
6
6
  * summary so an impact-analysis worker can understand the blast radius.
7
7
  *
8
8
  * Fail-open: missing optional source → empty field, brief still written.
9
+ *
10
+ * M94-D8-T4 [RULE] brief-resolves-active-milestone-never-completed:
11
+ * Cross-checks the resolved milestonePrefix against the Completed-Milestones
12
+ * table in progress.md. A prefix appearing there is COMPLETED and is rejected
13
+ * (staleness flag set), never returned as the active milestone prefix.
9
14
  */
10
15
 
11
16
  const fs = require('fs');
@@ -19,10 +24,90 @@ function _readMaybe(file) {
19
24
  try { return fs.readFileSync(file, 'utf8'); } catch (_) { return null; }
20
25
  }
21
26
 
27
+ /**
28
+ * Build the set of milestone prefixes (lowercase) that are COMPLETED.
29
+ * Sources:
30
+ * 1. The main Milestones table (## Milestones) — rows where Status column is COMPLETED/COMPLETE.
31
+ * 2. The Completed Milestones table (## Completed Milestones) — all data rows.
32
+ *
33
+ * [RULE] brief-resolves-active-milestone-never-completed
34
+ *
35
+ * @param {string} progressText
36
+ * @returns {Set<string>}
37
+ */
38
+ function _completedPrefixes(progressText) {
39
+ const completed = new Set();
40
+ if (!progressText) return completed;
41
+
42
+ const lines = progressText.split(/\r?\n/);
43
+ let inCompletedMilestonesTable = false;
44
+ let inMilestonesTable = false;
45
+
46
+ for (const line of lines) {
47
+ if (/^##\s+Completed\s+Milestones/i.test(line)) {
48
+ inCompletedMilestonesTable = true;
49
+ inMilestonesTable = false;
50
+ continue;
51
+ }
52
+ if (/^##\s+Milestones\s*$/i.test(line)) {
53
+ inMilestonesTable = true;
54
+ inCompletedMilestonesTable = false;
55
+ continue;
56
+ }
57
+ if (/^##\s+/.test(line)) {
58
+ inCompletedMilestonesTable = false;
59
+ inMilestonesTable = false;
60
+ continue;
61
+ }
62
+
63
+ if (inCompletedMilestonesTable) {
64
+ const match = line.match(/^\|\s*(M\d+)/i);
65
+ if (match) {
66
+ completed.add(match[1].toLowerCase());
67
+ }
68
+ }
69
+
70
+ if (inMilestonesTable) {
71
+ if (/^\|/.test(line)) {
72
+ const cols = line.split('|').slice(1, -1).map((c) => c.trim());
73
+ if (cols.length >= 3) {
74
+ const milestoneCol = cols[0];
75
+ const statusCol = cols[2];
76
+ const milestoneMatch = milestoneCol.match(/^(M\d+)/i);
77
+ if (milestoneMatch && /^COMPLET/i.test(statusCol)) {
78
+ completed.add(milestoneMatch[1].toLowerCase());
79
+ }
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return completed;
86
+ }
87
+
88
+ /**
89
+ * Resolve the ACTIVE milestone prefix (not completed).
90
+ *
91
+ * [RULE] brief-resolves-active-milestone-never-completed
92
+ *
93
+ * @param {string|null} progressText
94
+ * @returns {{ prefix: string|null, staleness: string|null }}
95
+ */
22
96
  function _currentMilestonePrefix(progressText) {
23
- if (!progressText) return null;
24
- const m = progressText.match(/^\|\s*(M\d+)\s*\|.*(DEFINED|PARTITIONED|PLANNED|EXECUTING|EXECUTED|VERIFY)/im);
25
- return m ? m[1].toLowerCase() : null;
97
+ if (!progressText) return { prefix: null, staleness: null };
98
+
99
+ const completed = _completedPrefixes(progressText);
100
+
101
+ const lines = progressText.split(/\r?\n/);
102
+ for (const line of lines) {
103
+ const m = line.match(/^\|\s*(M\d+)\s*\|.*(DEFINED|PARTITIONED|PLANNED|EXECUTING|EXECUTED|VERIFY)/i);
104
+ if (!m) continue;
105
+ const prefix = m[1].toLowerCase();
106
+ if (completed.has(prefix)) continue;
107
+ return { prefix, staleness: null };
108
+ }
109
+
110
+ return { prefix: null, staleness: 'no-active-milestone-row' };
26
111
  }
27
112
 
28
113
  function _integrationPointsExcerpt(projectDir, prefix, recordSource) {
@@ -75,7 +160,7 @@ function collect(ctx) {
75
160
  const progressText = _readMaybe(path.join(projectDir, PROGRESS_PATH));
76
161
  if (progressText) recordSource(PROGRESS_PATH);
77
162
 
78
- const prefix = _currentMilestonePrefix(progressText);
163
+ const { prefix, staleness } = _currentMilestonePrefix(progressText);
79
164
  const integration = _integrationPointsExcerpt(projectDir, prefix, recordSource);
80
165
 
81
166
  return {
@@ -87,6 +172,7 @@ function collect(ctx) {
87
172
  integrationPointsExcerpt: integration,
88
173
  gitDiffSummary: _gitDiffSummary(projectDir),
89
174
  changedFiles: _gitDiffNames(projectDir),
175
+ ...(staleness ? { staleness } : {}),
90
176
  },
91
177
  };
92
178
  }
@@ -97,4 +183,5 @@ module.exports = {
97
183
  collect,
98
184
  _currentMilestonePrefix,
99
185
  _integrationPointsExcerpt,
186
+ _completedPrefixes,
100
187
  };
@@ -6,6 +6,11 @@
6
6
  * domain table for partition-phase workers.
7
7
  *
8
8
  * Fail-open: missing optional source → empty field, brief still written.
9
+ *
10
+ * M94-D8-T4 [RULE] brief-resolves-active-milestone-never-completed:
11
+ * Cross-checks the resolved milestonePrefix against the Completed-Milestones
12
+ * table in progress.md. A prefix appearing there is COMPLETED and is rejected
13
+ * (staleness flag set), never returned as the active milestone prefix.
9
14
  */
10
15
 
11
16
  const fs = require('fs');
@@ -40,6 +45,92 @@ function _existingDomains(projectDir) {
40
45
  return entries.filter((e) => /^[a-z0-9_-]+$/i.test(e)).sort();
41
46
  }
42
47
 
48
+ /**
49
+ * Build the set of milestone prefixes (lowercase) that are COMPLETED.
50
+ * Sources:
51
+ * 1. The main Milestones table (## Milestones) — rows where Status column is COMPLETED/COMPLETE.
52
+ * 2. The Completed Milestones table (## Completed Milestones) — all data rows.
53
+ *
54
+ * [RULE] brief-resolves-active-milestone-never-completed
55
+ *
56
+ * @param {string} progressText
57
+ * @returns {Set<string>}
58
+ */
59
+ function _completedPrefixes(progressText) {
60
+ const completed = new Set();
61
+ if (!progressText) return completed;
62
+
63
+ const lines = progressText.split(/\r?\n/);
64
+ let inCompletedMilestonesTable = false;
65
+ let inMilestonesTable = false;
66
+
67
+ for (const line of lines) {
68
+ if (/^##\s+Completed\s+Milestones/i.test(line)) {
69
+ inCompletedMilestonesTable = true;
70
+ inMilestonesTable = false;
71
+ continue;
72
+ }
73
+ if (/^##\s+Milestones\s*$/i.test(line)) {
74
+ inMilestonesTable = true;
75
+ inCompletedMilestonesTable = false;
76
+ continue;
77
+ }
78
+ if (/^##\s+/.test(line)) {
79
+ inCompletedMilestonesTable = false;
80
+ inMilestonesTable = false;
81
+ continue;
82
+ }
83
+
84
+ if (inCompletedMilestonesTable) {
85
+ const match = line.match(/^\|\s*(M\d+)/i);
86
+ if (match) {
87
+ completed.add(match[1].toLowerCase());
88
+ }
89
+ }
90
+
91
+ if (inMilestonesTable) {
92
+ if (/^\|/.test(line)) {
93
+ const cols = line.split('|').slice(1, -1).map((c) => c.trim());
94
+ if (cols.length >= 3) {
95
+ const milestoneCol = cols[0];
96
+ const statusCol = cols[2];
97
+ const milestoneMatch = milestoneCol.match(/^(M\d+)/i);
98
+ if (milestoneMatch && /^COMPLET/i.test(statusCol)) {
99
+ completed.add(milestoneMatch[1].toLowerCase());
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return completed;
107
+ }
108
+
109
+ /**
110
+ * Resolve the ACTIVE milestone prefix (not completed).
111
+ *
112
+ * [RULE] brief-resolves-active-milestone-never-completed
113
+ *
114
+ * @param {string|null} progressText
115
+ * @returns {{ prefix: string|null, staleness: string|null }}
116
+ */
117
+ function _currentMilestonePrefix(progressText) {
118
+ if (!progressText) return { prefix: null, staleness: null };
119
+
120
+ const completed = _completedPrefixes(progressText);
121
+
122
+ const lines = progressText.split(/\r?\n/);
123
+ for (const line of lines) {
124
+ const m = line.match(/^\|\s*(M\d+)\s*\|.*(DEFINED|PARTITIONED|PLANNED|EXECUTING|EXECUTED|VERIFY)/i);
125
+ if (!m) continue;
126
+ const prefix = m[1].toLowerCase();
127
+ if (completed.has(prefix)) continue;
128
+ return { prefix, staleness: null };
129
+ }
130
+
131
+ return { prefix: null, staleness: 'no-active-milestone-row' };
132
+ }
133
+
43
134
  function collect(ctx) {
44
135
  const { projectDir, recordSource } = ctx;
45
136
 
@@ -50,6 +141,7 @@ function collect(ctx) {
50
141
  if (disjointRules) recordSource(DISJOINT_RULES_PATH);
51
142
 
52
143
  const milestoneRow = _currentMilestoneRow(progressText);
144
+ const { prefix, staleness } = _currentMilestonePrefix(progressText);
53
145
  const domains = _existingDomains(projectDir);
54
146
 
55
147
  // Trim disjoint-rules excerpt to ~1,200 chars to honor cap.
@@ -68,6 +160,7 @@ function collect(ctx) {
68
160
  currentMilestoneRow: milestoneRow,
69
161
  existingDomains: domains,
70
162
  disjointnessRulesExcerpt: rulesExcerpt,
163
+ ...(staleness ? { staleness } : {}),
71
164
  },
72
165
  };
73
166
  }
@@ -77,5 +170,7 @@ module.exports = {
77
170
  requiresSources: [],
78
171
  collect,
79
172
  _currentMilestoneRow,
173
+ _currentMilestonePrefix,
80
174
  _existingDomains,
175
+ _completedPrefixes,
81
176
  };
@@ -5,6 +5,12 @@
5
5
  * partitioned domain names with their scope.md "Files Owned" first-N entries.
6
6
  *
7
7
  * Fail-open: missing optional source → empty field, brief still written.
8
+ *
9
+ * M94-D8-T4 [RULE] brief-resolves-active-milestone-never-completed:
10
+ * The resolved milestonePrefix MUST belong to an ACTIVE (not COMPLETED) milestone.
11
+ * Cross-check against the Completed-Milestones table in progress.md before returning.
12
+ * If the resolved prefix appears in the Completed-Milestones table (or the main Milestones
13
+ * table with a COMPLETED status), it is rejected and ancillary.staleness is set.
8
14
  */
9
15
 
10
16
  const fs = require('fs');
@@ -28,6 +34,76 @@ function _currentMilestoneRow(progressText) {
28
34
  return null;
29
35
  }
30
36
 
37
+ /**
38
+ * Build the set of milestone prefixes (lowercase) that are COMPLETED.
39
+ * Sources:
40
+ * 1. The main Milestones table (## Milestones) — rows where the Status column is COMPLETED/COMPLETE.
41
+ * 2. The Completed Milestones table (## Completed Milestones) — all data rows.
42
+ *
43
+ * Returns a Set<string> of lowercase prefix strings like 'm65', 'm93', etc.
44
+ *
45
+ * @param {string} progressText
46
+ * @returns {Set<string>}
47
+ */
48
+ function _completedPrefixes(progressText) {
49
+ const completed = new Set();
50
+ if (!progressText) return completed;
51
+
52
+ const lines = progressText.split(/\r?\n/);
53
+ let inCompletedMilestonesTable = false;
54
+ let inMilestonesTable = false;
55
+
56
+ for (const line of lines) {
57
+ // Detect the "## Completed Milestones" section header.
58
+ if (/^##\s+Completed\s+Milestones/i.test(line)) {
59
+ inCompletedMilestonesTable = true;
60
+ inMilestonesTable = false;
61
+ continue;
62
+ }
63
+ // Detect the "## Milestones" section header (not "## Completed Milestones").
64
+ if (/^##\s+Milestones\s*$/i.test(line)) {
65
+ inMilestonesTable = true;
66
+ inCompletedMilestonesTable = false;
67
+ continue;
68
+ }
69
+ // Stop tracking on any other heading.
70
+ if (/^##\s+/.test(line)) {
71
+ inCompletedMilestonesTable = false;
72
+ inMilestonesTable = false;
73
+ continue;
74
+ }
75
+
76
+ if (inCompletedMilestonesTable) {
77
+ // All data rows in the Completed Milestones table are completed milestones.
78
+ // Row format: | M93 Brevity Guard | 4.9.11 | ...
79
+ // or: | M93 | ... | COMPLETED | ...
80
+ const match = line.match(/^\|\s*(M\d+)/i);
81
+ if (match) {
82
+ completed.add(match[1].toLowerCase());
83
+ }
84
+ }
85
+
86
+ if (inMilestonesTable) {
87
+ // Rows in the main Milestones table with COMPLETED or COMPLETE status.
88
+ // Row format: | M65 | Orchestration-Shell Retirement | COMPLETED | ...
89
+ // The status is the 3rd pipe-column.
90
+ if (/^\|/.test(line)) {
91
+ const cols = line.split('|').slice(1, -1).map((c) => c.trim());
92
+ if (cols.length >= 3) {
93
+ const milestoneCol = cols[0]; // e.g. "M65"
94
+ const statusCol = cols[2]; // e.g. "COMPLETED"
95
+ const milestoneMatch = milestoneCol.match(/^(M\d+)/i);
96
+ if (milestoneMatch && /^COMPLET/i.test(statusCol)) {
97
+ completed.add(milestoneMatch[1].toLowerCase());
98
+ }
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ return completed;
105
+ }
106
+
31
107
  function _domainSummaries(projectDir, currentMilestonePrefix, recordSource) {
32
108
  const dir = path.join(projectDir, '.gsd-t', 'domains');
33
109
  let entries;
@@ -57,10 +133,38 @@ function _domainSummaries(projectDir, currentMilestonePrefix, recordSource) {
57
133
  return out;
58
134
  }
59
135
 
136
+ /**
137
+ * Resolve the ACTIVE milestone prefix from the progress text.
138
+ * Returns the prefix string (e.g. 'm94') if found and NOT completed, or null.
139
+ * Also returns a staleness flag if a resolved prefix is completed.
140
+ *
141
+ * [RULE] brief-resolves-active-milestone-never-completed
142
+ *
143
+ * @param {string|null} progressText
144
+ * @returns {{ prefix: string|null, staleness: string|null }}
145
+ */
60
146
  function _currentMilestonePrefix(progressText) {
61
- if (!progressText) return null;
62
- const m = progressText.match(/^\|\s*(M\d+)\s*\|.*(DEFINED|PARTITIONED|PLANNED|EXECUTING|EXECUTED|VERIFY)/im);
63
- return m ? m[1].toLowerCase() : null;
147
+ if (!progressText) return { prefix: null, staleness: null };
148
+
149
+ // Build the completed set FIRST for cross-checking.
150
+ const completed = _completedPrefixes(progressText);
151
+
152
+ // Walk all matching rows and return the first one that is NOT completed.
153
+ const lines = progressText.split(/\r?\n/);
154
+ for (const line of lines) {
155
+ const m = line.match(/^\|\s*(M\d+)\s*\|.*(DEFINED|PARTITIONED|PLANNED|EXECUTING|EXECUTED|VERIFY)/i);
156
+ if (!m) continue;
157
+ const prefix = m[1].toLowerCase();
158
+ if (completed.has(prefix)) {
159
+ // This row's milestone is COMPLETED — skip it, continue scanning.
160
+ continue;
161
+ }
162
+ // Found an active (non-completed) milestone row.
163
+ return { prefix, staleness: null };
164
+ }
165
+
166
+ // No active milestone row found (or all candidates are completed).
167
+ return { prefix: null, staleness: 'no-active-milestone-row' };
64
168
  }
65
169
 
66
170
  function collect(ctx) {
@@ -70,7 +174,7 @@ function collect(ctx) {
70
174
  if (progressText) recordSource(PROGRESS_PATH);
71
175
 
72
176
  const milestoneRow = _currentMilestoneRow(progressText);
73
- const prefix = _currentMilestonePrefix(progressText);
177
+ const { prefix, staleness } = _currentMilestonePrefix(progressText);
74
178
  const domains = _domainSummaries(projectDir, prefix, recordSource);
75
179
 
76
180
  return {
@@ -81,6 +185,7 @@ function collect(ctx) {
81
185
  currentMilestoneRow: milestoneRow,
82
186
  milestonePrefix: prefix,
83
187
  partitionedDomains: domains,
188
+ ...(staleness ? { staleness } : {}),
84
189
  },
85
190
  };
86
191
  }
@@ -92,4 +197,5 @@ module.exports = {
92
197
  _currentMilestoneRow,
93
198
  _currentMilestonePrefix,
94
199
  _domainSummaries,
200
+ _completedPrefixes,
95
201
  };