@planu/cli 5.3.51 → 5.3.55

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 (40) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/dist/cli/commands/package-handoff.d.ts +3 -0
  3. package/dist/cli/commands/package-handoff.js +43 -0
  4. package/dist/cli/commands/spec.js +3 -8
  5. package/dist/cli/commands/status.d.ts +52 -1
  6. package/dist/cli/commands/status.js +39 -35
  7. package/dist/cli/router.js +5 -2
  8. package/dist/engine/git/canonical-branch.d.ts +2 -0
  9. package/dist/engine/git/canonical-branch.js +31 -0
  10. package/dist/engine/handoff-artifacts/schemas.d.ts +2 -0
  11. package/dist/engine/handoff-artifacts/schemas.js +1 -0
  12. package/dist/engine/human-summary.js +1 -1
  13. package/dist/engine/lifecycle-reconciliation.js +3 -0
  14. package/dist/engine/scope-boundaries/contradiction-checker.js +29 -5
  15. package/dist/engine/sdd-model-routing.js +7 -2
  16. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  17. package/dist/engine/staleness/stale-implementing.js +19 -58
  18. package/dist/engine/text-signal-boundaries.js +3 -1
  19. package/dist/engine/validator/spec-compliance-runner.d.ts +1 -0
  20. package/dist/engine/validator/spec-compliance-runner.js +13 -6
  21. package/dist/engine/validator/validation-report-writer.js +1 -0
  22. package/dist/engine/workflow-validator/worktree-protocol.js +45 -36
  23. package/dist/storage/transition-log.d.ts +2 -14
  24. package/dist/storage/transition-log.js +84 -45
  25. package/dist/tools/challenge-spec/challenge-report.js +42 -11
  26. package/dist/tools/challenge-spec/scenarios-utils.js +9 -2
  27. package/dist/tools/generate-orchestration-script.js +2 -1
  28. package/dist/tools/register-platform-tools/design-stack-tools.js +1 -1
  29. package/dist/tools/suggest-tooling/orchestration-generator.js +2 -2
  30. package/dist/tools/sync-spec-state-handler.js +20 -7
  31. package/dist/tools/update-status/dod-gates.js +36 -6
  32. package/dist/tools/update-status/file-sync.d.ts +1 -0
  33. package/dist/tools/update-status/file-sync.js +45 -13
  34. package/dist/tools/update-status/index.js +12 -1
  35. package/dist/tools/update-status/transition-guard.d.ts +1 -0
  36. package/dist/tools/update-status/transition-guard.js +50 -4
  37. package/dist/tools/validate.js +5 -2
  38. package/dist/types/handoff-artifacts.d.ts +2 -0
  39. package/package.json +20 -20
  40. package/planu-plugin.json +1 -1
@@ -20,6 +20,7 @@ export interface PerScenarioResult {
20
20
  title: string;
21
21
  verdict: ScenarioVerdict;
22
22
  evidence: string[];
23
+ unverifiable?: true;
23
24
  }
24
25
  export interface SpecComplianceResult {
25
26
  dimensionScore: number;
@@ -499,33 +499,40 @@ export async function runSpecCompliance(spec, projectPath, signal, canonicalProj
499
499
  }
500
500
  const evidence = [];
501
501
  const commandRow = resolveExactCommandRow(exactCommandRows, scenario);
502
- const verdicts = scenario.tests.map((source) => {
502
+ const linkResults = scenario.tests.map((source) => {
503
503
  if (!isExecutableTestLink(source)) {
504
504
  const error = commandLinkErrors.get(source);
505
505
  if (error) {
506
506
  evidence.push(`${source.path || '(missing path)'}: ${error}`);
507
- return 'missing';
507
+ return { verdict: 'missing', unverifiable: true };
508
508
  }
509
509
  const verdict = commandEvidenceCoversPath(commandRow, source.path) ? 'pass' : 'missing';
510
510
  evidence.push(`${source.path}: ${verdict === 'pass' ? 'passed by current command evidence' : 'missing current command evidence'}`);
511
- return verdict;
511
+ return { verdict, unverifiable: false };
512
512
  }
513
513
  const link = resolvedBySource.get(source);
514
514
  if (!link || link.error) {
515
515
  evidence.push(`${source.path || '(missing path)'}: ${link?.error ?? 'malformed test link'}`);
516
- return 'missing';
516
+ return { verdict: 'missing', unverifiable: true };
517
517
  }
518
518
  const adapter = statusByGroup.get(groupKey(link));
519
519
  const verdict = adapter?.statuses.get(link.path) ?? 'missing';
520
520
  evidence.push(`${link.path}: ${verdict === 'pass' ? 'passed' : verdict === 'fail' ? 'failed' : `not found in ${adapter?.detail ?? 'runner output'}`}`);
521
- return verdict;
521
+ return { verdict, unverifiable: false };
522
522
  });
523
+ const verdicts = linkResults.map((entry) => entry.verdict);
524
+ const unverifiable = linkResults.some((entry) => entry.unverifiable);
523
525
  const verdict = verdicts.every((item) => item === 'pass')
524
526
  ? 'pass'
525
527
  : verdicts.some((item) => item === 'missing')
526
528
  ? 'missing'
527
529
  : 'fail';
528
- return { title: scenario.title, verdict, evidence };
530
+ return {
531
+ title: scenario.title,
532
+ verdict,
533
+ evidence,
534
+ ...(unverifiable ? { unverifiable: true } : {}),
535
+ };
529
536
  });
530
537
  const passCount = perScenario.filter((scenario) => scenario.verdict === 'pass').length;
531
538
  return {
@@ -155,6 +155,7 @@ function toValidationReportCompliance(result) {
155
155
  title: scenario.title,
156
156
  verdict: scenario.verdict,
157
157
  evidence: scenario.evidence,
158
+ ...(scenario.unverifiable && { unverifiable: scenario.unverifiable }),
158
159
  })),
159
160
  };
160
161
  }
@@ -1,7 +1,8 @@
1
1
  // engine/workflow-validator/worktree-protocol.ts — SPEC-053
2
2
  // Worktree base validation and integration readiness checks.
3
3
  import { execSync } from 'node:child_process';
4
- // === Helpers ===
4
+ import { resolveCanonicalBranch } from '../git/canonical-branch.js';
5
+ const INTEGRATION_BRANCHES = ['main', 'master', 'develop'];
5
6
  function runCmd(cmd, cwd) {
6
7
  try {
7
8
  const output = execSync(cmd, {
@@ -19,57 +20,65 @@ function runCmd(cmd, cwd) {
19
20
  };
20
21
  }
21
22
  }
22
- function getWorktreeBranchBase(worktreePath) {
23
- const result = runCmd('git rev-parse --abbrev-ref HEAD', worktreePath);
24
- if (result.exitCode !== 0) {
25
- return '';
23
+ function getWorktreeBaseCandidates(worktreePath) {
24
+ const headResult = runCmd('git rev-parse --abbrev-ref HEAD', worktreePath);
25
+ if (headResult.exitCode !== 0) {
26
+ return [];
26
27
  }
27
- // Get the merge-base of the current branch with develop
28
- const developResult = runCmd('git rev-parse develop', worktreePath);
29
- if (developResult.exitCode !== 0) {
30
- return 'main'; // develop doesn't exist — likely based from main
31
- }
32
- const mergeBaseResult = runCmd(`git merge-base HEAD ${developResult.output}`, worktreePath);
33
- if (mergeBaseResult.exitCode !== 0) {
34
- return '';
35
- }
36
- // Check if merge-base equals develop HEAD (branch was from develop)
37
- if (mergeBaseResult.output === developResult.output) {
38
- return 'develop';
39
- }
40
- // Check if merge-base equals main HEAD
41
- const mainResult = runCmd('git rev-parse main', worktreePath);
42
- if (mainResult.exitCode === 0 && mergeBaseResult.output === mainResult.output) {
43
- return 'main';
44
- }
45
- return 'develop'; // assume develop as default
28
+ const containedInHead = INTEGRATION_BRANCHES.filter((branch) => {
29
+ const branchResult = runCmd(`git rev-parse --verify ${branch}`, worktreePath);
30
+ if (branchResult.exitCode !== 0) {
31
+ return false;
32
+ }
33
+ const mergeBaseResult = runCmd(`git merge-base HEAD ${branch}`, worktreePath);
34
+ return mergeBaseResult.exitCode === 0 && mergeBaseResult.output === branchResult.output;
35
+ });
36
+ return containedInHead.filter((branch) => !containedInHead.some((other) => other !== branch &&
37
+ runCmd(`git merge-base --is-ancestor ${branch} ${other}`, worktreePath).exitCode === 0 &&
38
+ runCmd(`git merge-base --is-ancestor ${other} ${branch}`, worktreePath).exitCode !== 0));
46
39
  }
47
- // === C13: Validate worktree was created from develop ===
48
40
  export function validateWorktreeBase(worktreePath) {
49
- const baseBranch = getWorktreeBranchBase(worktreePath);
50
- if (!baseBranch) {
41
+ const candidates = getWorktreeBaseCandidates(worktreePath);
42
+ const canonical = resolveCanonicalBranch(worktreePath);
43
+ const candidateList = candidates.join(', ');
44
+ if (candidates.length === 0) {
51
45
  return {
52
46
  valid: false,
53
47
  baseBranch: 'unknown',
54
48
  message: 'Could not determine the base branch of this worktree.',
55
- suggestion: 'Ensure git is initialized and both develop and main branches exist.',
49
+ suggestion: 'Ensure git is initialized and an integration branch (main, master, or develop) exists.',
50
+ };
51
+ }
52
+ if (canonical && !candidates.includes(canonical)) {
53
+ return {
54
+ valid: false,
55
+ baseBranch: candidateList,
56
+ message: `Worktree was created from '${candidateList}' instead of the canonical branch '${canonical}'.`,
57
+ suggestion: `Always create worktrees from ${canonical}: git worktree add .claude/worktrees/agent-XXX ${canonical}`,
58
+ };
59
+ }
60
+ if (candidates.length >= 2) {
61
+ return {
62
+ valid: true,
63
+ baseBranch: candidateList,
64
+ message: `Worktree base is ambiguous between ${candidateList}; HEAD contains both.`,
56
65
  };
57
66
  }
58
- if (baseBranch === 'develop') {
67
+ const onlyCandidate = candidates[0] ?? '';
68
+ if (!canonical) {
59
69
  return {
60
70
  valid: true,
61
- baseBranch: 'develop',
62
- message: 'Worktree is correctly based on develop.',
71
+ baseBranch: onlyCandidate,
72
+ message: `Worktree is based on '${onlyCandidate}', but the canonical branch could not be determined so the base was not verified.`,
73
+ suggestion: 'Run git remote set-head origin -a to record the canonical branch.',
63
74
  };
64
75
  }
65
76
  return {
66
- valid: false,
67
- baseBranch,
68
- message: `Worktree was created from '${baseBranch}' instead of 'develop'.`,
69
- suggestion: 'Always create worktrees from develop: git worktree add .claude/worktrees/agent-XXX develop',
77
+ valid: true,
78
+ baseBranch: onlyCandidate,
79
+ message: `Worktree is correctly based on ${onlyCandidate}.`,
70
80
  };
71
81
  }
72
- // === C15: Integration readiness — typecheck + lint must pass ===
73
82
  export function validateIntegrationReadiness(projectPath) {
74
83
  const issues = [];
75
84
  const typecheckResult = runCmd('pnpm typecheck', projectPath);
@@ -1,23 +1,11 @@
1
1
  import type { TransitionLogEntry, AppendTransitionEventInput, VerifyChainResult } from '../types/transition-log.js';
2
+ export declare function transitionLogPath(projectId: string): string;
2
3
  /**
3
4
  * Append a transition event to the project's transition log.
4
5
  * Writes are serialized per file to preserve hash-chain integrity.
5
6
  */
6
7
  export declare function appendTransitionEvent(input: AppendTransitionEventInput): Promise<TransitionLogEntry>;
7
- /**
8
- * Read all transition log entries for a given project and spec.
9
- * Returns an empty array if the file does not exist.
10
- */
11
8
  export declare function readTransitionLog(projectId: string, specId?: string): Promise<TransitionLogEntry[]>;
12
- /**
13
- * SPEC-734: Verify the hash-chain integrity of a project's transition log.
14
- *
15
- * For each entry, recomputes `sha` from `canonicalEntry(entry)` and checks
16
- * that `prevSha` matches the previous entry's `sha`. Legacy entries that
17
- * predate SPEC-734 (missing `sessionId`/`modelId`/`gateResults`) are
18
- * tolerated — their sha was computed without those fields and remains valid
19
- * because `canonicalEntry` serialises undefined fields as JSON `undefined`
20
- * (i.e. they are omitted from the string, same as when they were written).
21
- */
22
9
  export declare function verifyTransitionLogChain(projectId: string): Promise<VerifyChainResult>;
10
+ export declare function streamJsonlLines(filePath: string): AsyncGenerator<string>;
23
11
  //# sourceMappingURL=transition-log.d.ts.map
@@ -5,14 +5,15 @@
5
5
  // Each entry is a JSON object on its own line. The `sha` field is a SHA-256 hash
6
6
  // of the entry's canonical JSON (excluding `sha`), chained via `prevSha`.
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
- import { appendFile, readFile, mkdir } from 'node:fs/promises';
8
+ import { appendFile, mkdir } from 'node:fs/promises';
9
+ import { createReadStream } from 'node:fs';
9
10
  import { dirname, join } from 'node:path';
10
11
  import { projectDataDir } from './base-store.js';
11
12
  import { reportClassifiedDegradation } from '../errors/classified-degradation.js';
12
13
  // ---------------------------------------------------------------------------
13
14
  // Path helper
14
15
  // ---------------------------------------------------------------------------
15
- function transitionLogPath(projectId) {
16
+ export function transitionLogPath(projectId) {
16
17
  return join(projectDataDir(projectId), 'transition-log.jsonl');
17
18
  }
18
19
  // ---------------------------------------------------------------------------
@@ -55,11 +56,12 @@ function computeEntrySha(entry) {
55
56
  const writeQueues = new Map();
56
57
  async function doAppend(filePath, input) {
57
58
  await mkdir(dirname(filePath), { recursive: true });
58
- // Read last entry's sha to chain
59
- const lines = await readJsonlLines(filePath);
59
+ let lastLine;
60
+ for await (const line of streamJsonlLines(filePath)) {
61
+ lastLine = line;
62
+ }
60
63
  let prevSha = '';
61
- if (lines.length > 0) {
62
- const lastLine = lines[lines.length - 1] ?? '';
64
+ if (lastLine !== undefined) {
63
65
  try {
64
66
  const parsed = JSON.parse(lastLine);
65
67
  prevSha = typeof parsed.sha === 'string' ? parsed.sha : '';
@@ -107,15 +109,10 @@ export function appendTransitionEvent(input) {
107
109
  writeQueues.set(filePath, next.then(() => undefined, () => undefined));
108
110
  return next.then(() => result);
109
111
  }
110
- /**
111
- * Read all transition log entries for a given project and spec.
112
- * Returns an empty array if the file does not exist.
113
- */
114
112
  export async function readTransitionLog(projectId, specId) {
115
113
  const filePath = transitionLogPath(projectId);
116
- const lines = await readJsonlLines(filePath);
117
114
  const entries = [];
118
- for (const line of lines) {
115
+ for await (const line of streamJsonlLines(filePath)) {
119
116
  try {
120
117
  const entry = JSON.parse(line);
121
118
  if (specId === undefined || entry.specId === specId) {
@@ -130,64 +127,106 @@ export async function readTransitionLog(projectId, specId) {
130
127
  }
131
128
  return entries;
132
129
  }
133
- /**
134
- * SPEC-734: Verify the hash-chain integrity of a project's transition log.
135
- *
136
- * For each entry, recomputes `sha` from `canonicalEntry(entry)` and checks
137
- * that `prevSha` matches the previous entry's `sha`. Legacy entries that
138
- * predate SPEC-734 (missing `sessionId`/`modelId`/`gateResults`) are
139
- * tolerated — their sha was computed without those fields and remains valid
140
- * because `canonicalEntry` serialises undefined fields as JSON `undefined`
141
- * (i.e. they are omitted from the string, same as when they were written).
142
- */
143
130
  export async function verifyTransitionLogChain(projectId) {
144
131
  const filePath = transitionLogPath(projectId);
145
- const lines = await readJsonlLines(filePath);
146
- if (lines.length === 0) {
147
- return { valid: true, totalEntries: 0, brokenAt: null };
148
- }
149
132
  let prevSha = '';
150
- for (let i = 0; i < lines.length; i++) {
133
+ let totalEntries = 0;
134
+ for await (const line of streamJsonlLines(filePath)) {
135
+ const index = totalEntries;
136
+ totalEntries++;
151
137
  let entry;
152
138
  try {
153
- entry = JSON.parse(lines[i] ?? '');
139
+ entry = JSON.parse(line);
154
140
  }
155
141
  catch {
156
- return { valid: false, totalEntries: lines.length, brokenAt: i };
142
+ return { valid: false, totalEntries, brokenAt: index };
157
143
  }
158
- // Recompute sha from all fields except 'sha' itself
159
144
  const { sha: storedSha, ...rest } = entry;
160
145
  const expectedSha = computeEntrySha(rest);
161
146
  if (storedSha !== expectedSha) {
162
- return { valid: false, totalEntries: lines.length, brokenAt: i };
147
+ return { valid: false, totalEntries, brokenAt: index };
163
148
  }
164
- if (i === 0) {
165
- // First entry must have prevSha === '' (genesis)
166
- if (entry.prevSha !== '') {
167
- return { valid: false, totalEntries: lines.length, brokenAt: 0 };
168
- }
169
- }
170
- else if (entry.prevSha !== prevSha) {
171
- return { valid: false, totalEntries: lines.length, brokenAt: i };
149
+ const expectedPrevSha = index === 0 ? '' : prevSha;
150
+ if (entry.prevSha !== expectedPrevSha) {
151
+ return { valid: false, totalEntries, brokenAt: index };
172
152
  }
173
153
  prevSha = storedSha;
174
154
  }
175
- return { valid: true, totalEntries: lines.length, brokenAt: null };
155
+ return { valid: true, totalEntries, brokenAt: null };
176
156
  }
177
157
  // ---------------------------------------------------------------------------
178
158
  // Internal helpers
179
159
  // ---------------------------------------------------------------------------
180
- async function readJsonlLines(filePath) {
181
- let raw;
160
+ const MAX_LINE_BYTES = 64 * 1024 * 1024;
161
+ export async function* streamJsonlLines(filePath) {
162
+ const stream = createReadStream(filePath);
163
+ let pending = [];
164
+ let pendingLength = 0;
165
+ let discardingOversizedLine = false;
166
+ function resetPending() {
167
+ pending = [];
168
+ pendingLength = 0;
169
+ }
170
+ function* drainCompleteLines(buf) {
171
+ let start = 0;
172
+ let newlineIndex = buf.indexOf(0x0a, start);
173
+ while (newlineIndex !== -1) {
174
+ const line = buf.toString('utf-8', start, newlineIndex);
175
+ if (line.trim().length > 0) {
176
+ yield line;
177
+ }
178
+ start = newlineIndex + 1;
179
+ newlineIndex = buf.indexOf(0x0a, start);
180
+ }
181
+ const remainder = buf.subarray(start);
182
+ if (remainder.length > 0) {
183
+ pending = [Buffer.from(remainder)];
184
+ pendingLength = remainder.length;
185
+ }
186
+ }
187
+ function* processChunk(chunk) {
188
+ let data = chunk;
189
+ if (discardingOversizedLine) {
190
+ const newlineIndex = data.indexOf(0x0a);
191
+ if (newlineIndex === -1) {
192
+ return;
193
+ }
194
+ discardingOversizedLine = false;
195
+ data = data.subarray(newlineIndex + 1);
196
+ if (data.length === 0) {
197
+ return;
198
+ }
199
+ }
200
+ if (!data.includes(0x0a)) {
201
+ pending.push(Buffer.from(data));
202
+ pendingLength += data.length;
203
+ if (pendingLength > MAX_LINE_BYTES) {
204
+ reportClassifiedDegradation('OVERSIZED_TRANSITION_LINE', new Error(`Line exceeds ${MAX_LINE_BYTES} bytes without a newline: ${filePath}`));
205
+ resetPending();
206
+ discardingOversizedLine = true;
207
+ }
208
+ return;
209
+ }
210
+ const buf = pendingLength > 0 ? Buffer.concat([...pending, data]) : Buffer.from(data);
211
+ resetPending();
212
+ yield* drainCompleteLines(buf);
213
+ }
182
214
  try {
183
- raw = await readFile(filePath, 'utf-8');
215
+ for await (const chunk of stream) {
216
+ yield* processChunk(chunk);
217
+ }
184
218
  }
185
219
  catch (err) {
186
220
  if (isNodeError(err) && err.code === 'ENOENT') {
187
- return [];
221
+ return;
188
222
  }
189
223
  throw err;
190
224
  }
191
- return raw.split('\n').filter((l) => l.trim().length > 0);
225
+ if (pendingLength > 0) {
226
+ const line = Buffer.concat(pending).toString('utf-8');
227
+ if (line.trim().length > 0) {
228
+ yield line;
229
+ }
230
+ }
192
231
  }
193
232
  //# sourceMappingURL=transition-log.js.map
@@ -24,10 +24,12 @@ function uniqueMatchingEvidence(evidence, scenarioNames) {
24
24
  }
25
25
  return [...matched.values()];
26
26
  }
27
+ const NEGATED_ACCEPTANCE_RE = /\b(?:not|never|no|cannot|can not|can't|won't|isn't|aren't|doesn't|didn't)\b(?:\W+\w+){0,2}\W+(?:accepted|accept risk|accepted risk|known risk)\b/i;
27
28
  function inferResolution(text) {
28
- return /\b(accepted|accept risk|accepted risk|known risk)\b/i.test(text)
29
- ? 'accepted'
30
- : 'mitigated';
29
+ if (!/\b(accepted|accept risk|accepted risk|known risk)\b/i.test(text)) {
30
+ return 'mitigated';
31
+ }
32
+ return NEGATED_ACCEPTANCE_RE.test(text) ? 'mitigated' : 'accepted';
31
33
  }
32
34
  function extractChallengeResolutionSection(specContent) {
33
35
  const normalized = specContent.replace(/\r\n/g, '\n');
@@ -37,11 +39,40 @@ function extractChallengeResolutionSection(specContent) {
37
39
  return '';
38
40
  }
39
41
  const start = match.index + match[0].length;
40
- const nextRe = /^###\s+\S/gm;
42
+ const nextRe = /^#{2,3}\s+\S/gm;
41
43
  nextRe.lastIndex = start;
42
44
  const next = nextRe.exec(normalized);
43
45
  return normalized.slice(start, next ? next.index : normalized.length).trim();
44
46
  }
47
+ function collapseWhitespace(text) {
48
+ return text.replace(/\s+/g, ' ').trim();
49
+ }
50
+ function foldChallengeResolutionBullets(section) {
51
+ const bullets = [];
52
+ let current = null;
53
+ for (const rawLine of section.split('\n')) {
54
+ const line = rawLine.trim();
55
+ if (/^-\s+/.test(line)) {
56
+ if (current !== null) {
57
+ bullets.push(current);
58
+ }
59
+ current = line.replace(/^-\s+/, '');
60
+ }
61
+ else if (line.length === 0) {
62
+ if (current !== null) {
63
+ bullets.push(current);
64
+ }
65
+ current = null;
66
+ }
67
+ else if (current !== null) {
68
+ current += ` ${line}`;
69
+ }
70
+ }
71
+ if (current !== null) {
72
+ bullets.push(current);
73
+ }
74
+ return bullets.map(collapseWhitespace);
75
+ }
45
76
  export function parseChallengeResolutionEvidence(specContent, scenarios, runAt) {
46
77
  const section = extractChallengeResolutionSection(specContent);
47
78
  if (section.length === 0 || scenarios.length === 0) {
@@ -52,23 +83,23 @@ export function parseChallengeResolutionEvidence(specContent, scenarios, runAt)
52
83
  if (!singleScenario) {
53
84
  return [];
54
85
  }
86
+ const collapsedSection = collapseWhitespace(section);
87
+ if (!collapsedSection.includes(collapseWhitespace(singleScenario.scenario))) {
88
+ return [];
89
+ }
55
90
  return [
56
91
  {
57
92
  scenario: singleScenario.scenario,
58
93
  resolution: inferResolution(section),
59
- evidence: section.replace(/\s+/g, ' ').trim(),
94
+ evidence: collapsedSection,
60
95
  resolvedAt: runAt,
61
96
  },
62
97
  ];
63
98
  }
64
- const bullets = section
65
- .split('\n')
66
- .map((line) => line.trim())
67
- .filter((line) => /^-\s+/.test(line))
68
- .map((line) => line.replace(/^-\s+/, '').trim());
99
+ const bullets = foldChallengeResolutionBullets(section);
69
100
  const evidence = [];
70
101
  for (const bullet of bullets) {
71
- const scenario = scenarios.find((item) => bullet.includes(item.scenario));
102
+ const scenario = scenarios.find((item) => bullet.includes(collapseWhitespace(item.scenario)));
72
103
  if (!scenario) {
73
104
  continue;
74
105
  }
@@ -2,8 +2,6 @@
2
2
  import { hasAffirmedMatch, hasAnyAffirmedMatch, stripMetaAnalysisText, stripNonContractText, } from '../../engine/text-signal-boundaries.js';
3
3
  const CAPABILITY_SIGNALS = {
4
4
  networkApi: [
5
- /(?<!compiler\s)\bapi\b(?!\s+(?:compatibility|surface|package|type))/i,
6
- /\b(?:public|external|remote|rest|http)\s+api\b/i,
7
5
  /\bapi\s+(?:endpoint|route|request|response|server|client)\b/i,
8
6
  /\b(?:http|rest)\s+(?:endpoint|route|request|response)\b/i,
9
7
  /\bfetch(?:es|ing)?\s+(?:from\s+)?(?:an?\s+)?(?:endpoint|service|url)\b/i,
@@ -104,6 +102,14 @@ function hasProductAuthenticationEvidence(contract) {
104
102
  return (hasAffirmedMatch(contract, AUTHENTICATION_BARE_TOKEN_RE) &&
105
103
  !REGISTRY_AUTH_CONTEXT_RE.test(contract));
106
104
  }
105
+ const NETWORK_API_BARE_TOKEN_RE = /(?<!compiler\s)\bapi\b(?!\s+(?:compatibility|surface|package|type))/i;
106
+ const NETWORK_API_CONTEXT_RE = /\b(?:endpoint|route|http|https|rest|fetch|url|socket|request|response|timeout|retry|webhook|payload|keys?)\b(?:\W+\w+){0,6}?\W+api\b|\bapi\b(?:\W+\w+){0,6}?\W+\b(?:endpoint|route|http|https|rest|fetch|url|socket|request|response|timeout|retry|webhook|payload|keys?)\b/i;
107
+ function hasProductNetworkEvidence(contract) {
108
+ if (hasAnyAffirmedMatch(contract, CAPABILITY_SIGNALS.networkApi)) {
109
+ return true;
110
+ }
111
+ return (hasAffirmedMatch(contract, NETWORK_API_BARE_TOKEN_RE) && NETWORK_API_CONTEXT_RE.test(contract));
112
+ }
107
113
  function keywordPattern(keyword) {
108
114
  const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
109
115
  const suffix = /^[a-z0-9]+$/i.test(keyword) && keyword.length > 3 ? '[a-z0-9_-]*' : '';
@@ -139,6 +145,7 @@ export function detectChallengeCapabilities(spec, specContent) {
139
145
  hasAnyAffirmedMatch(contract, patterns),
140
146
  ]));
141
147
  capabilities.authentication = hasProductAuthenticationEvidence(contract);
148
+ capabilities.networkApi = hasProductNetworkEvidence(contract);
142
149
  return capabilities;
143
150
  }
144
151
  export function hasAnyChallengeCapability(capabilities, names) {
@@ -1,6 +1,7 @@
1
1
  // tools/generate-orchestration-script.ts — SPEC-022 TD: 3-layer parallel automation script generator
2
2
  // Generates a bash orchestration script for parallel multi-agent execution via git worktrees.
3
3
  import { t } from '../i18n/index.js';
4
+ import { resolveCanonicalBranch } from '../engine/git/canonical-branch.js';
4
5
  import { generateOrchestrationScript } from './suggest-tooling/orchestration-generator.js';
5
6
  export function handleGenerateOrchestrationScript(args) {
6
7
  const { specIds, projectPath, mainBranch, baseBranch } = args;
@@ -21,7 +22,7 @@ export function handleGenerateOrchestrationScript(args) {
21
22
  '',
22
23
  `**Specs:** ${specIds.join(', ')}`,
23
24
  `**Project path:** ${projectPath}`,
24
- `**Base branch:** ${baseBranch ?? 'develop'}`,
25
+ `**Base branch:** ${(baseBranch ?? resolveCanonicalBranch(projectPath)) || 'main'}`,
25
26
  '',
26
27
  '### Usage',
27
28
  '',
@@ -218,7 +218,7 @@ export function registerDesignStackTools(server) {
218
218
  .string()
219
219
  .max(500)
220
220
  .optional()
221
- .describe('Base branch for worktrees (default: develop)'),
221
+ .describe("Base branch for worktrees (default: the project's canonical branch)"),
222
222
  },
223
223
  }, safeGoverned('generate_orchestration_script', (args) => Promise.resolve(handleGenerateOrchestrationScript(args))));
224
224
  // 32. red_team
@@ -1,7 +1,7 @@
1
1
  // tools/suggest-tooling/orchestration-generator.ts — SPEC-022 TD: 3-layer orchestration script generator
2
2
  // Generates a parallel automation bash script: validate → worktrees → parallel claude -p → wait → report → cleanup
3
+ import { resolveCanonicalBranch } from '../../engine/git/canonical-branch.js';
3
4
  const DEFAULT_MAIN_BRANCH = 'main';
4
- const DEFAULT_BASE_BRANCH = 'develop';
5
5
  const WORKTREE_BASE = '.claude/worktrees';
6
6
  /**
7
7
  * Generates a parallel orchestration bash script that:
@@ -15,7 +15,7 @@ const WORKTREE_BASE = '.claude/worktrees';
15
15
  export function generateOrchestrationScript(input) {
16
16
  const { specIds, projectPath, mainBranch, baseBranch } = input;
17
17
  const main = mainBranch ?? DEFAULT_MAIN_BRANCH;
18
- const base = baseBranch ?? DEFAULT_BASE_BRANCH;
18
+ const base = (baseBranch ?? resolveCanonicalBranch(projectPath)) || 'main';
19
19
  const worktreeSetupLines = buildWorktreeSetup(specIds, base);
20
20
  const launchLines = buildParallelLaunch(specIds, projectPath);
21
21
  const waitLines = buildWaitAndReport(specIds);
@@ -10,7 +10,7 @@ import { hashProjectPath } from '../storage/base-store.js';
10
10
  import { handleUpdateStatus } from './update-status/index.js';
11
11
  import { parseFrontmatter } from '../engine/frontmatter-parser.js';
12
12
  import { verifyTerminalFrontmatter } from '../engine/frontmatter-sha/index.js';
13
- import { appendTransitionEvent } from '../storage/transition-log.js';
13
+ import { appendTransitionEvent, streamJsonlLines, transitionLogPath, } from '../storage/transition-log.js';
14
14
  import { cleanEphemeralArtifacts } from '../engine/housekeeping/index.js';
15
15
  import { readFile, stat } from 'node:fs/promises';
16
16
  // ---------------------------------------------------------------------------
@@ -142,14 +142,24 @@ export async function syncSpecState(projectPath, projectId) {
142
142
  }
143
143
  return report;
144
144
  }
145
- /**
146
- * SPEC-1351 (AC3): scan the data store for entries whose spec.md is missing on
147
- * disk and quarantine each one — appends a `ghost_spec_quarantined` transition
148
- * event (surfaced by workspace_alerts with a recovery action) and returns the
149
- * list so callers can report it inline.
150
- */
145
+ async function collectQuarantinedSpecIds(projectId) {
146
+ const quarantined = new Set();
147
+ for await (const line of streamJsonlLines(transitionLogPath(projectId))) {
148
+ try {
149
+ const entry = JSON.parse(line);
150
+ if (entry.eventType === 'ghost_spec_quarantined' && typeof entry.specId === 'string') {
151
+ quarantined.add(entry.specId);
152
+ }
153
+ }
154
+ catch {
155
+ continue;
156
+ }
157
+ }
158
+ return quarantined;
159
+ }
151
160
  async function detectGhostSpecs(projectId, dataEntries) {
152
161
  const ghosts = [];
162
+ const alreadyQuarantined = await collectQuarantinedSpecIds(projectId);
153
163
  await Promise.all(dataEntries.map(async (entry) => {
154
164
  if (!entry.specPath) {
155
165
  return;
@@ -165,6 +175,9 @@ async function detectGhostSpecs(projectId, dataEntries) {
165
175
  }
166
176
  const reason = `Store entry ${entry.id} references spec.md at ${entry.specPath}, which is missing or empty on disk. Recreate the spec via create_spec or restore spec.md from git/backup.`;
167
177
  ghosts.push({ specId: entry.id, specPath: entry.specPath, reason });
178
+ if (alreadyQuarantined.has(entry.id)) {
179
+ return;
180
+ }
168
181
  void appendTransitionEvent({
169
182
  projectId,
170
183
  specId: entry.id,