@planu/cli 5.3.52 → 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.
- package/CHANGELOG.md +80 -0
- package/dist/cli/commands/package-handoff.d.ts +3 -0
- package/dist/cli/commands/package-handoff.js +43 -0
- package/dist/cli/commands/spec.js +3 -8
- package/dist/cli/commands/status.d.ts +52 -1
- package/dist/cli/commands/status.js +39 -35
- package/dist/cli/router.js +5 -2
- package/dist/engine/git/canonical-branch.d.ts +2 -0
- package/dist/engine/git/canonical-branch.js +31 -0
- package/dist/engine/handoff-artifacts/schemas.d.ts +2 -0
- package/dist/engine/handoff-artifacts/schemas.js +1 -0
- package/dist/engine/human-summary.js +1 -1
- package/dist/engine/lifecycle-reconciliation.js +3 -0
- package/dist/engine/scope-boundaries/contradiction-checker.js +29 -5
- package/dist/engine/sdd-model-routing.js +7 -2
- package/dist/engine/spec-format/lean-spec-generator.js +1 -1
- package/dist/engine/staleness/stale-implementing.js +19 -58
- package/dist/engine/text-signal-boundaries.js +3 -1
- package/dist/engine/validator/spec-compliance-runner.d.ts +1 -0
- package/dist/engine/validator/spec-compliance-runner.js +13 -6
- package/dist/engine/validator/validation-report-writer.js +1 -0
- package/dist/engine/workflow-validator/worktree-protocol.js +45 -36
- package/dist/storage/transition-log.d.ts +2 -14
- package/dist/storage/transition-log.js +84 -45
- package/dist/tools/challenge-spec/challenge-report.js +42 -11
- package/dist/tools/challenge-spec/scenarios-utils.js +9 -2
- package/dist/tools/generate-orchestration-script.js +2 -1
- package/dist/tools/register-platform-tools/design-stack-tools.js +1 -1
- package/dist/tools/suggest-tooling/orchestration-generator.js +2 -2
- package/dist/tools/sync-spec-state-handler.js +20 -7
- package/dist/tools/update-status/dod-gates.js +36 -6
- package/dist/tools/update-status/file-sync.d.ts +1 -0
- package/dist/tools/update-status/file-sync.js +45 -13
- package/dist/tools/update-status/index.js +12 -1
- package/dist/tools/update-status/transition-guard.d.ts +1 -0
- package/dist/tools/update-status/transition-guard.js +50 -4
- package/dist/tools/validate.js +5 -2
- package/dist/types/handoff-artifacts.d.ts +2 -0
- package/package.json +1 -1
- package/planu-plugin.json +1 -1
|
@@ -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
|
|
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 {
|
|
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 {
|
|
@@ -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
|
-
|
|
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
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
67
|
+
const onlyCandidate = candidates[0] ?? '';
|
|
68
|
+
if (!canonical) {
|
|
59
69
|
return {
|
|
60
70
|
valid: true,
|
|
61
|
-
baseBranch:
|
|
62
|
-
message:
|
|
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:
|
|
67
|
-
baseBranch,
|
|
68
|
-
message: `Worktree
|
|
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,
|
|
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
|
-
|
|
59
|
-
const
|
|
59
|
+
let lastLine;
|
|
60
|
+
for await (const line of streamJsonlLines(filePath)) {
|
|
61
|
+
lastLine = line;
|
|
62
|
+
}
|
|
60
63
|
let prevSha = '';
|
|
61
|
-
if (
|
|
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
|
|
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
|
-
|
|
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(
|
|
139
|
+
entry = JSON.parse(line);
|
|
154
140
|
}
|
|
155
141
|
catch {
|
|
156
|
-
return { valid: false, totalEntries
|
|
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
|
|
147
|
+
return { valid: false, totalEntries, brokenAt: index };
|
|
163
148
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
155
|
+
return { valid: true, totalEntries, brokenAt: null };
|
|
176
156
|
}
|
|
177
157
|
// ---------------------------------------------------------------------------
|
|
178
158
|
// Internal helpers
|
|
179
159
|
// ---------------------------------------------------------------------------
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 =
|
|
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:
|
|
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 ?? '
|
|
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(
|
|
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 ??
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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,
|