@planu/cli 4.11.9 → 4.12.2

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 (91) hide show
  1. package/CHANGELOG.md +129 -22
  2. package/dist/engine/abortable-process-runner.d.ts +8 -0
  3. package/dist/engine/abortable-process-runner.js +107 -0
  4. package/dist/engine/cascade-hooks/core/append-releases.d.ts +1 -0
  5. package/dist/engine/cascade-hooks/core/append-releases.js +51 -14
  6. package/dist/engine/cascade-hooks/hooks/orchestration-plan.hook.js +16 -21
  7. package/dist/engine/ci-generator/yaml-builder.js +5 -1
  8. package/dist/engine/context-md-generator.js +3 -1
  9. package/dist/engine/criterion-identity.d.ts +6 -0
  10. package/dist/engine/criterion-identity.js +63 -0
  11. package/dist/engine/evidence-gates/lifecycle-gate.js +22 -44
  12. package/dist/engine/evidence-index/index-builder.js +7 -19
  13. package/dist/engine/frontmatter-parser.d.ts +5 -0
  14. package/dist/engine/frontmatter-parser.js +24 -0
  15. package/dist/engine/project-command-runtime.d.ts +5 -0
  16. package/dist/{tools/validate-runtime.js → engine/project-command-runtime.js} +8 -21
  17. package/dist/engine/project-graph/builder.js +1 -35
  18. package/dist/engine/project-graph/cache.js +37 -1
  19. package/dist/engine/qa-gate.d.ts +4 -1
  20. package/dist/engine/qa-gate.js +72 -59
  21. package/dist/engine/safety/cross-process-lock.d.ts +1 -6
  22. package/dist/engine/safety/cross-process-lock.js +18 -16
  23. package/dist/engine/safety/lock-scavenger.d.ts +9 -0
  24. package/dist/engine/safety/lock-scavenger.js +114 -0
  25. package/dist/engine/session-context-generator.js +24 -2
  26. package/dist/engine/session-safeguard/learnings-buffer.js +7 -2
  27. package/dist/engine/session-state/writer.js +15 -8
  28. package/dist/engine/skill-registry/installer.js +1 -1
  29. package/dist/engine/spec-format/acceptance-criteria.js +8 -26
  30. package/dist/engine/spec-grounding/contract.d.ts +3 -1
  31. package/dist/engine/spec-grounding/contract.js +39 -14
  32. package/dist/engine/universal-rules/installer.js +2 -2
  33. package/dist/engine/validation-evidence-digest.d.ts +4 -0
  34. package/dist/engine/validation-evidence-digest.js +83 -0
  35. package/dist/engine/validation-evidence-ledger.d.ts +30 -0
  36. package/dist/engine/validation-evidence-ledger.js +274 -0
  37. package/dist/engine/validation-impact-planner.d.ts +4 -0
  38. package/dist/engine/validation-impact-planner.js +71 -0
  39. package/dist/engine/validator/spec-compliance-runner.d.ts +1 -1
  40. package/dist/engine/validator/spec-compliance-runner.js +37 -40
  41. package/dist/engine/validator/validation-report-writer.d.ts +1 -0
  42. package/dist/engine/validator/validation-report-writer.js +1 -1
  43. package/dist/engine/validator.js +7 -11
  44. package/dist/storage/convention-baseline.js +5 -0
  45. package/dist/storage/session-state-store.js +13 -6
  46. package/dist/storage/spec-store.d.ts +5 -0
  47. package/dist/storage/spec-store.js +7 -0
  48. package/dist/tools/git/git-helpers.d.ts +5 -1
  49. package/dist/tools/git/git-helpers.js +3 -2
  50. package/dist/tools/init-project/conventions-writer.js +1 -1
  51. package/dist/tools/init-project/scaffold-writer.js +1 -1
  52. package/dist/tools/safe-handler.d.ts +5 -1
  53. package/dist/tools/safe-handler.js +73 -12
  54. package/dist/tools/status-handler.d.ts +1 -1
  55. package/dist/tools/status-handler.js +27 -66
  56. package/dist/tools/update-status/dod-gates.js +7 -31
  57. package/dist/tools/update-status/evidence-gate.js +2 -4
  58. package/dist/tools/update-status/force-done-audit.d.ts +2 -0
  59. package/dist/tools/update-status/force-done-audit.js +23 -0
  60. package/dist/tools/update-status/qa-gate.d.ts +1 -0
  61. package/dist/tools/update-status/qa-gate.js +7 -51
  62. package/dist/tools/update-status-convention-gate.js +132 -97
  63. package/dist/tools/validate-lint.d.ts +1 -1
  64. package/dist/tools/validate-lint.js +78 -165
  65. package/dist/tools/validate.js +3 -1
  66. package/dist/transports/http-transport.js +22 -8
  67. package/dist/transports/mcp-types.d.ts +2 -2
  68. package/dist/transports/transport-factory.js +110 -12
  69. package/dist/types/abortable-process.d.ts +26 -0
  70. package/dist/types/abortable-process.js +2 -0
  71. package/dist/types/criterion-identity.d.ts +12 -0
  72. package/dist/types/criterion-identity.js +2 -0
  73. package/dist/types/index.d.ts +2 -0
  74. package/dist/types/index.js +2 -0
  75. package/dist/types/qa-gate.d.ts +0 -5
  76. package/dist/types/spec-format.d.ts +2 -0
  77. package/dist/types/spec-lock-v2.d.ts +7 -0
  78. package/dist/types/validation-evidence.d.ts +62 -0
  79. package/dist/types/validation-evidence.js +2 -0
  80. package/package.json +43 -23
  81. package/planu-native.json +1 -1
  82. package/planu-plugin.json +1 -1
  83. package/dist/storage/qa-gate-store.d.ts +0 -4
  84. package/dist/storage/qa-gate-store.js +0 -24
  85. package/dist/tools/validate-runtime.d.ts +0 -14
  86. package/dist/types/data/estimation.d.ts +0 -147
  87. package/dist/types/data/estimation.js +0 -2
  88. package/dist/types/data/index.d.ts +0 -5
  89. package/dist/types/data/index.js +0 -6
  90. package/dist/types/data/velocity.d.ts +0 -168
  91. package/dist/types/data/velocity.js +0 -4
@@ -1,4 +1,5 @@
1
1
  import { hasAnyAffirmedMatch } from '../text-signal-boundaries.js';
2
+ import { createCriterionIdentity } from '../criterion-identity.js';
2
3
  const CONTRACT_HINTS = {
3
4
  api: [
4
5
  /\b(?:public|external|rest|http)[ -]?api\b/i,
@@ -20,30 +21,6 @@ const CONTRACT_HINTS = {
20
21
  function isNonTrivial(spec) {
21
22
  return spec.scope !== 'trivial';
22
23
  }
23
- function normalizeCriterion(value) {
24
- const withoutCheckbox = value.replace(/^-\s*\[[ x]\]\s*/i, '').trim();
25
- const criterionOnly = /^([A-Z]{2}\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
26
- if (criterionOnly) {
27
- return criterionOnly.toLowerCase();
28
- }
29
- return withoutCheckbox
30
- .replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
31
- .replace(/\s+/g, ' ')
32
- .trim()
33
- .toLowerCase();
34
- }
35
- function criterionAliases(value, index) {
36
- const aliases = new Set([
37
- normalizeCriterion(value),
38
- `ac${String(index + 1)}`,
39
- `ab${String(index + 1)}`,
40
- ]);
41
- const explicit = /^([A-Z]{2}\d+)\b/i.exec(value)?.[1];
42
- if (explicit) {
43
- aliases.add(normalizeCriterion(explicit));
44
- }
45
- return [...aliases].filter((alias) => alias.length > 0);
46
- }
47
24
  function hasText(value) {
48
25
  return value !== undefined && value.trim().length > 0;
49
26
  }
@@ -90,8 +67,8 @@ export function checkTaskPlanGate(spec, criteria, artifacts) {
90
67
  },
91
68
  ];
92
69
  }
93
- const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.map(normalizeCriterion)));
94
- const uncovered = criteria.filter((criterion, index) => !criterionAliases(criterion, index).some((alias) => covered.has(alias)));
70
+ const covered = new Set(artifacts.taskPlan.tasks.flatMap((task) => task.acceptanceCriteria.flatMap((criterion, index) => createCriterionIdentity(criterion, { index }).aliases)));
71
+ const uncovered = criteria.filter((criterion, index) => !createCriterionIdentity(criterion, { index }).aliases.some((alias) => covered.has(alias)));
95
72
  if (uncovered.length === 0) {
96
73
  return [];
97
74
  }
@@ -108,6 +85,22 @@ function rowHasEvidence(row) {
108
85
  (row.contractEvidence !== undefined && row.contractEvidence.length > 0) ||
109
86
  hasText(row.manualEvidence));
110
87
  }
88
+ function describeIncompleteRow(row) {
89
+ const missing = [];
90
+ if (!rowHasEvidence(row)) {
91
+ missing.push('scenario/testEvidence/contractEvidence/manualEvidence');
92
+ }
93
+ if (row.changedFiles.length === 0) {
94
+ missing.push('changedFiles');
95
+ }
96
+ if (!hasText(row.validationEvidence)) {
97
+ missing.push('validationEvidence');
98
+ }
99
+ if (!hasText(row.reviewerEvidence)) {
100
+ missing.push('reviewerEvidence');
101
+ }
102
+ return `${row.acceptanceCriterion}: missing ${missing.join(', ')}`;
103
+ }
111
104
  export function checkDoneEvidenceGate(spec, criteria, artifacts) {
112
105
  const issues = [];
113
106
  const matrix = artifacts.traceabilityMatrix;
@@ -118,8 +111,8 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
118
111
  });
119
112
  }
120
113
  else {
121
- const covered = new Set(matrix.rows.map((row) => normalizeCriterion(row.acceptanceCriterion)));
122
- const uncovered = criteria.filter((criterion, index) => !criterionAliases(criterion, index).some((alias) => covered.has(alias)));
114
+ const covered = new Set(matrix.rows.flatMap((row, index) => createCriterionIdentity(row.acceptanceCriterion, { index }).aliases));
115
+ const uncovered = criteria.filter((criterion, index) => !createCriterionIdentity(criterion, { index }).aliases.some((alias) => covered.has(alias)));
123
116
  if (uncovered.length > 0) {
124
117
  issues.push({
125
118
  code: 'traceability_uncovered_criteria',
@@ -131,22 +124,7 @@ export function checkDoneEvidenceGate(spec, criteria, artifacts) {
131
124
  !hasText(row.validationEvidence) ||
132
125
  !hasText(row.reviewerEvidence));
133
126
  if (incompleteRows.length > 0) {
134
- const rowDetails = incompleteRows.map((row) => {
135
- const missing = [];
136
- if (!rowHasEvidence(row)) {
137
- missing.push('scenario/testEvidence/contractEvidence/manualEvidence');
138
- }
139
- if (row.changedFiles.length === 0) {
140
- missing.push('changedFiles');
141
- }
142
- if (!hasText(row.validationEvidence)) {
143
- missing.push('validationEvidence');
144
- }
145
- if (!hasText(row.reviewerEvidence)) {
146
- missing.push('reviewerEvidence');
147
- }
148
- return `${row.acceptanceCriterion}: missing ${missing.join(', ')}`;
149
- });
127
+ const rowDetails = incompleteRows.map(describeIncompleteRow);
150
128
  issues.push({
151
129
  code: 'traceability_incomplete_rows',
152
130
  message: `Traceability rows must include evidence, changed files, validation evidence, and reviewer evidence. Incomplete rows: ${rowDetails.join('; ')}`,
@@ -1,16 +1,16 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { stat } from 'node:fs/promises';
3
2
  import { isAbsolute, join, normalize } from 'node:path';
3
+ import { createCriterionIdentity, criterionIdentitiesMatch } from '../criterion-identity.js';
4
4
  export function criterionKey(criterion) {
5
- const normalized = normalizeCriterion(criterion);
6
- return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
5
+ return createCriterionIdentity(criterion).stableKey;
7
6
  }
8
7
  export async function buildSpecEvidenceIndex(args) {
9
8
  const diagnostics = [...args.artifacts.invalidArtifacts];
10
9
  const matrixRows = args.artifacts.traceabilityMatrix?.rows ?? [];
11
10
  const contractValidations = args.artifacts.contractValidations;
12
- const entries = await Promise.all(args.criteria.map(async (criterion) => {
13
- const rows = matrixRows.filter((row) => normalizeCriterion(row.acceptanceCriterion) === normalizeCriterion(criterion));
11
+ const entries = await Promise.all(args.criteria.map(async (criterion, criterionIndex) => {
12
+ const identity = createCriterionIdentity(criterion, { index: criterionIndex });
13
+ const rows = matrixRows.filter((row, rowIndex) => criterionIdentitiesMatch(createCriterionIdentity(row.acceptanceCriterion, { index: rowIndex }), identity));
14
14
  const evidence = await collectEvidence({
15
15
  rows,
16
16
  contractValidations,
@@ -19,7 +19,7 @@ export async function buildSpecEvidenceIndex(args) {
19
19
  });
20
20
  return {
21
21
  criterion,
22
- criterionKey: criterionKey(criterion),
22
+ criterionKey: identity.stableKey,
23
23
  evidence,
24
24
  };
25
25
  }));
@@ -149,7 +149,7 @@ function looksLikeShellCommand(value) {
149
149
  if (/\s/.test(trimmed) && !/\.[A-Za-z0-9]+$/.test(trimmed)) {
150
150
  return true;
151
151
  }
152
- return /^(?:pnpm|npm|yarn|bun|npx|node|tsx|tsc|vitest|jest|git|bash|sh)\b/.test(trimmed);
152
+ return /^(?:pnpm|npm|yarn|bun|npx|node|tsx|tsc|vitest|jest|git|bash|sh)(?:\s|$)/.test(trimmed);
153
153
  }
154
154
  function looksLikeGlob(value) {
155
155
  return /[*?[\]{}]/.test(value);
@@ -157,16 +157,4 @@ function looksLikeGlob(value) {
157
157
  function record(kind, value, status, source, reason) {
158
158
  return { kind, value, status, source, ...(reason ? { reason } : {}) };
159
159
  }
160
- function normalizeCriterion(value) {
161
- const withoutCheckbox = value.replace(/^-\s*\[[ x]\]\s*/i, '').trim();
162
- const criterionOnly = /^([A-Z]{2}\d+)\s*[:.)-]?\s*$/i.exec(withoutCheckbox)?.[1];
163
- if (criterionOnly) {
164
- return criterionOnly.toLowerCase();
165
- }
166
- return withoutCheckbox
167
- .replace(/^[A-Z]{2}\d+\s*[:.)-]?\s*/i, '')
168
- .replace(/\s+/g, ' ')
169
- .trim()
170
- .toLowerCase();
171
- }
172
160
  //# sourceMappingURL=index-builder.js.map
@@ -5,6 +5,11 @@ import type { FrontmatterResult } from '../types/frontmatter.js';
5
5
  * Returns the original content as body if no frontmatter is found.
6
6
  */
7
7
  export declare function parseFrontmatter(content: string): FrontmatterResult;
8
+ /**
9
+ * Read one unambiguous top-level string field from the canonical frontmatter block.
10
+ * Missing, malformed, nested, non-string, or duplicated fields fail closed.
11
+ */
12
+ export declare function getUniqueTopLevelFrontmatterString(content: string, key: string): string | null;
8
13
  /**
9
14
  * Strip YAML frontmatter from markdown content, returning only the body.
10
15
  * If no frontmatter is present, returns the original content unchanged.
@@ -36,6 +36,30 @@ export function parseFrontmatter(content) {
36
36
  }
37
37
  return { metadata, body: match[2] };
38
38
  }
39
+ /**
40
+ * Read one unambiguous top-level string field from the canonical frontmatter block.
41
+ * Missing, malformed, nested, non-string, or duplicated fields fail closed.
42
+ */
43
+ export function getUniqueTopLevelFrontmatterString(content, key) {
44
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) {
45
+ return null;
46
+ }
47
+ const match = FRONTMATTER_RE.exec(content);
48
+ if (!match?.[1] || match[2] === undefined) {
49
+ return null;
50
+ }
51
+ const fieldPattern = new RegExp(`^${key}:\\s*(.*)$`);
52
+ const values = match[1]
53
+ .split('\n')
54
+ .map((line) => fieldPattern.exec(line)?.[1])
55
+ .filter((value) => value !== undefined);
56
+ const value = values[0];
57
+ if (values.length !== 1 || value === undefined) {
58
+ return null;
59
+ }
60
+ const parsed = parseScalar(value.trim());
61
+ return typeof parsed === 'string' && parsed.length > 0 ? parsed : null;
62
+ }
39
63
  function parseScalar(value) {
40
64
  if (value.startsWith('[') && value.endsWith(']')) {
41
65
  return value
@@ -0,0 +1,5 @@
1
+ import type { AbortableProcessResult, ProjectCommandPlan } from '../types/abortable-process.js';
2
+ export declare function resolveProjectCommandPlan(projectPath: string, requestedCommand: string, corepackAvailable?: boolean): ProjectCommandPlan;
3
+ export declare function formatLintFailureDiagnostics(plan: ProjectCommandPlan, output: string): string;
4
+ export declare function runProjectCommandPlan(plan: ProjectCommandPlan, projectPath: string, timeoutMs: number, signal?: AbortSignal): Promise<AbortableProcessResult>;
5
+ //# sourceMappingURL=project-command-runtime.d.ts.map
@@ -1,6 +1,5 @@
1
- // tools/validate-runtime.ts — Project-aware command execution helpers for validate.
2
- import { execFileSync } from 'node:child_process';
3
1
  import { readFileSync } from 'node:fs';
2
+ import { runAbortableProcess } from './abortable-process-runner.js';
4
3
  function readProjectPackageManager(projectPath) {
5
4
  try {
6
5
  const raw = readFileSync(`${projectPath}/package.json`, 'utf-8');
@@ -14,20 +13,7 @@ function readProjectPackageManager(projectPath) {
14
13
  function shellTokens(command) {
15
14
  return command.split(/\s+/).filter((token) => token.length > 0);
16
15
  }
17
- function canRunCorepack(projectPath) {
18
- try {
19
- execFileSync('corepack', ['--version'], {
20
- cwd: projectPath,
21
- stdio: ['ignore', 'ignore', 'ignore'],
22
- timeout: 5_000,
23
- });
24
- return true;
25
- }
26
- catch {
27
- return false;
28
- }
29
- }
30
- export function resolveProjectCommandPlan(projectPath, requestedCommand, corepackAvailable = canRunCorepack(projectPath)) {
16
+ export function resolveProjectCommandPlan(projectPath, requestedCommand, corepackAvailable = true) {
31
17
  const projectPackageManager = readProjectPackageManager(projectPath);
32
18
  const tokens = shellTokens(requestedCommand);
33
19
  const usesPnpm = tokens[0] === 'pnpm';
@@ -75,11 +61,12 @@ export function formatLintFailureDiagnostics(plan, output) {
75
61
  }
76
62
  return lines.filter((line, index) => index !== 0 || line.length > 0).join('\n');
77
63
  }
78
- export function runProjectCommandPlan(plan, projectPath, timeoutMs) {
79
- execFileSync(plan.executable, plan.args, {
64
+ export function runProjectCommandPlan(plan, projectPath, timeoutMs, signal) {
65
+ return runAbortableProcess(plan.executable, plan.args, {
80
66
  cwd: projectPath,
81
- stdio: ['pipe', 'pipe', 'pipe'],
82
- timeout: timeoutMs,
67
+ timeoutMs,
68
+ maxBufferBytes: 64 * 1024 * 1024,
69
+ signal,
83
70
  });
84
71
  }
85
- //# sourceMappingURL=validate-runtime.js.map
72
+ //# sourceMappingURL=project-command-runtime.js.map
@@ -135,37 +135,6 @@ function nativeCoreCacheVersion(runtime) {
135
135
  : 'typescript-fallback';
136
136
  return `${NATIVE_PROJECT_GRAPH_IMPLEMENTATION_VERSION}:${mode}`;
137
137
  }
138
- async function collectGitSource(projectPath) {
139
- try {
140
- const { execFile } = await import('node:child_process');
141
- const stdout = await new Promise((resolveOutput, reject) => {
142
- execFile('git', ['rev-parse', 'HEAD'], { cwd: projectPath }, (error, output) => {
143
- if (error) {
144
- reject(new Error('git rev-parse failed', { cause: error }));
145
- return;
146
- }
147
- resolveOutput(output);
148
- });
149
- });
150
- const head = stdout.trim();
151
- if (head.length === 0) {
152
- return null;
153
- }
154
- return {
155
- id: 'project-git',
156
- kind: 'project-git',
157
- path: '.git/log',
158
- content: head,
159
- hash: hashText(head),
160
- size: Buffer.byteLength(head),
161
- mtimeMs: 0,
162
- contentLoaded: true,
163
- };
164
- }
165
- catch {
166
- return null;
167
- }
168
- }
169
138
  async function extractSourceFragment(args) {
170
139
  if (args.source.kind === 'spec') {
171
140
  return extractSpecGraph(args.source, args.policy);
@@ -209,16 +178,13 @@ async function buildProjectKnowledgeGraphUnlocked(args) {
209
178
  const cacheResult = await readProjectGraphCache(args.projectId, args.policy);
210
179
  const previousCache = cacheResult.cache;
211
180
  const previousRecords = previousCache?.records ?? {};
212
- const collected = await collectProjectGraphSources({
181
+ let sources = await collectProjectGraphSources({
213
182
  projectId: args.projectId,
214
183
  projectPath: args.projectPath,
215
184
  policy: args.policy,
216
185
  previous: previousRecords,
217
186
  nativeCoreVersion,
218
187
  });
219
- const gitSource = await collectGitSource(args.projectPath);
220
- let sources = gitSource === null ? collected : [...collected, gitSource];
221
- sources.sort((a, b) => a.id.localeCompare(b.id));
222
188
  let diff = diffSourceHashes(sources, previousRecords, nativeCoreVersion, {
223
189
  fragments: previousCache?.fragments,
224
190
  knownSourceIds: cacheResult.knownSourceIds,
@@ -1,6 +1,7 @@
1
1
  /* eslint-disable max-lines -- cache validation, collection, and freshness share one artifact contract. */
2
2
  // @crash-shield-ignore-file - graph artifacts are Planu-owned JSON written by this subsystem.
3
3
  import { createHash } from 'node:crypto';
4
+ import { execFile } from 'node:child_process';
4
5
  import { open, readdir, readFile, stat } from 'node:fs/promises';
5
6
  import { dirname, extname, join, relative, resolve } from 'node:path';
6
7
  import { setTimeout as delay } from 'node:timers/promises';
@@ -422,6 +423,36 @@ export async function hydrateProjectGraphSource(source, maxFileBytes) {
422
423
  maxFileBytes,
423
424
  });
424
425
  }
426
+ async function collectGitSource(projectPath) {
427
+ try {
428
+ const stdout = await new Promise((resolveOutput, reject) => {
429
+ execFile('git', ['rev-parse', 'HEAD'], { cwd: projectPath }, (error, output) => {
430
+ if (error) {
431
+ reject(new Error('git rev-parse failed', { cause: error }));
432
+ return;
433
+ }
434
+ resolveOutput(output);
435
+ });
436
+ });
437
+ const head = stdout.trim();
438
+ if (head.length === 0) {
439
+ return null;
440
+ }
441
+ return {
442
+ id: 'project-git',
443
+ kind: 'project-git',
444
+ path: '.git/log',
445
+ content: head,
446
+ hash: hashText(head),
447
+ size: Buffer.byteLength(head),
448
+ mtimeMs: 0,
449
+ contentLoaded: true,
450
+ };
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }
425
456
  // eslint-disable-next-line max-lines-per-function -- source inventory is normalized in one deterministic pass.
426
457
  export async function collectProjectGraphSources(args) {
427
458
  const projectRoot = resolve(args.projectPath);
@@ -482,7 +513,12 @@ export async function collectProjectGraphSources(args) {
482
513
  nativeCoreVersion: args.nativeCoreVersion,
483
514
  maxFileBytes,
484
515
  })));
485
- return sources.filter((source) => source !== null);
516
+ const available = sources.filter((source) => source !== null);
517
+ const gitSource = await collectGitSource(projectRoot);
518
+ if (gitSource !== null) {
519
+ available.push(gitSource);
520
+ }
521
+ return available.sort((left, right) => left.id.localeCompare(right.id));
486
522
  }
487
523
  export async function getProjectGraphFreshness(args) {
488
524
  const policy = args.policy ?? (await loadProjectGraphPolicy());
@@ -2,5 +2,8 @@ import type { QaGateResult } from '../types/qa-gate.js';
2
2
  import type { Spec } from '../types/spec/core.js';
3
3
  export declare function extractCoverageThreshold(spec: Spec): number | null;
4
4
  export declare function computeQaGateFingerprint(spec: Spec, projectPath: string): string;
5
- export declare function runQaGate(spec: Spec, projectPath: string): Promise<QaGateResult>;
5
+ export declare function runQaGate(spec: Spec, projectPath: string, options?: {
6
+ projectId?: string;
7
+ signal?: AbortSignal;
8
+ }): Promise<QaGateResult>;
6
9
  //# sourceMappingURL=qa-gate.d.ts.map
@@ -1,22 +1,12 @@
1
1
  // engine/qa-gate.ts — SPEC-642
2
- import { spawnSync } from 'node:child_process';
3
- import { createHash } from 'node:crypto';
4
- import { readFileSync, statSync } from 'node:fs';
2
+ import { readFileSync } from 'node:fs';
5
3
  import { join } from 'node:path';
6
- import { resolveProjectCommandPlan } from '../tools/validate-runtime.js';
4
+ import { resolveProjectCommandPlan } from './project-command-runtime.js';
5
+ import { computeValidationEvidenceIdentity, computeValidationEvidenceKey, readMatchingValidationEvidence, recordSuccessfulValidationEvidence, recordValidationOutcome, } from './validation-evidence-ledger.js';
6
+ import { runAbortableProcess } from './abortable-process-runner.js';
7
+ import { VALIDATION_COMMANDS } from './validation-impact-planner.js';
7
8
  const TIMEOUT_MS = 120_000;
8
9
  const MAX_OUTPUT_BUFFER_BYTES = 64 * 1024 * 1024;
9
- const QA_FINGERPRINT_FILES = [
10
- 'package.json',
11
- 'pnpm-lock.yaml',
12
- 'package-lock.json',
13
- 'yarn.lock',
14
- 'bun.lockb',
15
- 'tsconfig.json',
16
- 'vitest.config.ts',
17
- 'vitest.config.mts',
18
- 'vitest.config.js',
19
- ];
20
10
  export function extractCoverageThreshold(spec) {
21
11
  const text = [spec.title, ...spec.tags].join(' ');
22
12
  const match = /coverage\s*[>=]+\s*(\d+)%/i.exec(text);
@@ -25,34 +15,75 @@ export function extractCoverageThreshold(spec) {
25
15
  }
26
16
  return parseInt(match[1], 10);
27
17
  }
28
- function runCheck(name, command, args, cwd) {
18
+ async function runCheck(name, command, args, cwd, signal) {
29
19
  const start = Date.now();
30
- const result = spawnSync(command, args, {
20
+ const result = await runAbortableProcess(command, args, {
31
21
  cwd,
32
- timeout: TIMEOUT_MS,
33
- encoding: 'utf-8',
34
- maxBuffer: MAX_OUTPUT_BUFFER_BYTES,
22
+ timeoutMs: TIMEOUT_MS,
23
+ maxBufferBytes: MAX_OUTPUT_BUFFER_BYTES,
24
+ signal,
35
25
  });
36
26
  const durationMs = Date.now() - start;
37
27
  const passed = result.status === 0 && !result.error;
38
28
  const output = [result.stdout, result.stderr].join('\n').trim().slice(0, 2000);
39
- const errorCode = result.error && 'code' in result.error
40
- ? String(result.error.code)
41
- : undefined;
42
- const timedOut = errorCode === 'ETIMEDOUT' || result.signal === 'SIGTERM';
43
29
  return {
44
30
  name,
45
31
  passed,
46
32
  output,
47
33
  durationMs,
48
34
  command: [command, ...args].join(' '),
49
- timedOut,
35
+ timedOut: result.timedOut,
50
36
  errorMessage: result.error?.message,
51
37
  };
52
38
  }
53
- function runRequestedCheck(name, requestedCommand, cwd) {
39
+ async function runRequestedCheck(name, requestedCommand, cwd, projectId, signal) {
54
40
  const plan = resolveProjectCommandPlan(cwd, requestedCommand);
55
- return runCheck(name, plan.executable, plan.args, cwd);
41
+ const input = {
42
+ projectPath: cwd,
43
+ capability: name === 'typecheck' ? 'typecheck' : 'test-full',
44
+ command: requestedCommand,
45
+ scope: ['src', 'tests'],
46
+ };
47
+ const expectedKey = computeValidationEvidenceKey(computeValidationEvidenceIdentity(input));
48
+ if (projectId) {
49
+ const receipt = readMatchingValidationEvidence(projectId, input);
50
+ if (receipt) {
51
+ return {
52
+ name,
53
+ passed: true,
54
+ output: `reused canonical validation receipt ${receipt.key}`,
55
+ durationMs: 0,
56
+ command: plan.executedCommand,
57
+ };
58
+ }
59
+ }
60
+ const result = await runCheck(name, plan.executable, plan.args, cwd, signal);
61
+ if (projectId && result.passed) {
62
+ recordSuccessfulValidationEvidence({
63
+ projectId,
64
+ input,
65
+ expectedKey,
66
+ producer: 'planu-qa-gate',
67
+ durationMs: result.durationMs,
68
+ output: result.output,
69
+ reason: `${name} passed during the canonical QA gate.`,
70
+ });
71
+ }
72
+ else if (projectId) {
73
+ recordValidationOutcome({
74
+ projectId,
75
+ input,
76
+ action: result.timedOut || signal?.aborted === true ? 'aborted' : 'executed',
77
+ producer: 'planu-qa-gate',
78
+ status: result.timedOut || signal?.aborted === true ? 'cancelled' : 'failed',
79
+ durationMs: result.durationMs,
80
+ output: result.output,
81
+ reason: result.timedOut || signal?.aborted === true
82
+ ? `${name} was cancelled before evidence persistence.`
83
+ : `${name} failed during the canonical QA gate.`,
84
+ });
85
+ }
86
+ return result;
56
87
  }
57
88
  function readProjectPackageManager(projectPath) {
58
89
  try {
@@ -67,57 +98,39 @@ function readProjectPackageManager(projectPath) {
67
98
  function resolveRequestedQaCommand(projectPath, check) {
68
99
  const manager = readProjectPackageManager(projectPath)?.split('@')[0] ?? 'pnpm';
69
100
  if (check === 'typecheck') {
70
- return `${manager} typecheck`;
101
+ return manager === 'pnpm' ? VALIDATION_COMMANDS.typecheck : `${manager} typecheck`;
71
102
  }
72
103
  if (check === 'test-coverage') {
73
104
  return `${manager} test:coverage`;
74
105
  }
75
- return `${manager} test`;
76
- }
77
- function asStableString(value) {
78
- return typeof value === 'string' ? value : '';
79
- }
80
- function asStableStringArray(value) {
81
- return Array.isArray(value)
82
- ? value.filter((item) => typeof item === 'string')
83
- : [];
106
+ return manager === 'pnpm' ? VALIDATION_COMMANDS['test-full'] : `${manager} test`;
84
107
  }
85
108
  export function computeQaGateFingerprint(spec, projectPath) {
86
- const hash = createHash('sha256');
87
- hash.update(spec.id);
88
- hash.update(asStableString(spec.updatedAt));
89
- hash.update(asStableString(spec.title));
90
- hash.update(asStableStringArray(spec.tags).join('|'));
91
- for (const file of QA_FINGERPRINT_FILES) {
92
- const fullPath = join(projectPath, file);
93
- try {
94
- const stats = statSync(fullPath);
95
- hash.update(`${file}:${String(stats.mtimeMs)}:${String(stats.size)}`);
96
- }
97
- catch {
98
- hash.update(`${file}:missing`);
99
- }
100
- }
101
- return hash.digest('hex');
109
+ return computeValidationEvidenceKey(computeValidationEvidenceIdentity({
110
+ projectPath,
111
+ capability: extractCoverageThreshold(spec) === null ? 'test-full' : 'test-full',
112
+ command: resolveRequestedQaCommand(projectPath, extractCoverageThreshold(spec) === null ? 'test' : 'test-coverage'),
113
+ scope: ['src', 'tests'],
114
+ }));
102
115
  }
103
- export function runQaGate(spec, projectPath) {
116
+ export async function runQaGate(spec, projectPath, options = {}) {
104
117
  const coverageThreshold = extractCoverageThreshold(spec);
105
118
  const checks = [];
106
- checks.push(runRequestedCheck('typecheck', resolveRequestedQaCommand(projectPath, 'typecheck'), projectPath));
119
+ checks.push(await runRequestedCheck('typecheck', resolveRequestedQaCommand(projectPath, 'typecheck'), projectPath, options.projectId, options.signal));
107
120
  if (coverageThreshold === null) {
108
- checks.push(runRequestedCheck('test', resolveRequestedQaCommand(projectPath, 'test'), projectPath));
121
+ checks.push(await runRequestedCheck('test', resolveRequestedQaCommand(projectPath, 'test'), projectPath, options.projectId, options.signal));
109
122
  }
110
123
  else {
111
- checks.push(runRequestedCheck('test-coverage', resolveRequestedQaCommand(projectPath, 'test-coverage'), projectPath));
124
+ checks.push(await runRequestedCheck('test-coverage', resolveRequestedQaCommand(projectPath, 'test-coverage'), projectPath, options.projectId, options.signal));
112
125
  }
113
126
  const passed = checks.every((c) => c.passed);
114
- return Promise.resolve({
127
+ return {
115
128
  specId: spec.id,
116
129
  passed,
117
130
  checks,
118
131
  ranAt: new Date().toISOString(),
119
132
  coverageThreshold,
120
133
  fingerprint: computeQaGateFingerprint(spec, projectPath),
121
- });
134
+ };
122
135
  }
123
136
  //# sourceMappingURL=qa-gate.js.map
@@ -1,4 +1,5 @@
1
1
  import type { CrossProcessLockMetadata, AcquireLockOptions, LockHandle, LockInfo } from '../../types/spec-lock-v2.js';
2
+ export { getLockScavengeMetrics, resetLockScavengeMetricsForTests, scavengeStaleLockArtifacts, } from './lock-scavenger.js';
2
3
  declare const HEARTBEAT_INTERVAL_MS = 30000;
3
4
  export { HEARTBEAT_INTERVAL_MS };
4
5
  /** Thrown by withSpecLock when the lock is held by another live session. */
@@ -13,12 +14,6 @@ export declare class LockBusyError extends Error {
13
14
  * Returns a LockHandle on success, or null if the lock is held by another live session.
14
15
  * Stale locks (PID dead, heartbeat expired, or TTL expired) are reclaimed automatically.
15
16
  *
16
- * Implementation:
17
- * - Attempt `fs.open(lockPath, 'wx')` (O_EXCL) — succeeds only when no lockfile exists.
18
- * - On EEXIST, read the existing lockfile and check staleness.
19
- * - If stale: overwrite atomically via SPEC-718 writeJsonSafe and return a new handle.
20
- * - If live: log a structured warning and return null.
21
- *
22
17
  * @remarks
23
18
  * NFS / SMB caveat: O_EXCL is not guaranteed atomic on network filesystems.
24
19
  * This implementation is reliable on local POSIX filesystems (APFS, HFS+, ext4, tmpfs).
@@ -1,21 +1,16 @@
1
1
  // src/engine/safety/cross-process-lock.ts — Cross-process spec lockfile (SPEC-719)
2
2
  //
3
- // Provides disk-backed mutual exclusion across Node.js processes for spec.md mutations.
4
- // Stale lock detection uses three signals: PID death, heartbeat expiry, TTL expiry.
5
- //
6
- // NFS / Network FS caveat: lockfile correctness assumes local POSIX filesystem semantics.
7
- // SMB / NFSv3 may produce false acquires because O_EXCL is not guaranteed atomic on
8
- // network filesystems. For local-disk POSIX (HFS+, APFS, ext4, tmpfs) this is reliable.
9
- //
10
3
  // Lockfiles live at: <projectPath>/data/.locks/planu/<specId>.lock
11
4
  // File mode: 0o600 (owner read/write only)
12
- import { open, unlink, readFile, mkdir, chmod } from 'node:fs/promises';
5
+ import { open, unlink, readFile, mkdir, chmod, readdir } from 'node:fs/promises';
13
6
  import { existsSync } from 'node:fs';
14
- import { join } from 'node:path';
7
+ import { basename, dirname, join } from 'node:path';
15
8
  import { hostname as osHostname } from 'node:os';
16
9
  import { randomUUID } from 'node:crypto';
17
10
  import { writeJsonSafe } from './atomic-writer.js';
18
11
  import { enrichWithStaleness } from './lock-staleness.js';
12
+ import { readValidatedLockFile, scavengeStaleLockArtifacts } from './lock-scavenger.js';
13
+ export { getLockScavengeMetrics, resetLockScavengeMetricsForTests, scavengeStaleLockArtifacts, } from './lock-scavenger.js';
19
14
  // ---------------------------------------------------------------------------
20
15
  // Constants
21
16
  // ---------------------------------------------------------------------------
@@ -139,17 +134,12 @@ function buildHandle(metadata, path) {
139
134
  * Returns a LockHandle on success, or null if the lock is held by another live session.
140
135
  * Stale locks (PID dead, heartbeat expired, or TTL expired) are reclaimed automatically.
141
136
  *
142
- * Implementation:
143
- * - Attempt `fs.open(lockPath, 'wx')` (O_EXCL) — succeeds only when no lockfile exists.
144
- * - On EEXIST, read the existing lockfile and check staleness.
145
- * - If stale: overwrite atomically via SPEC-718 writeJsonSafe and return a new handle.
146
- * - If live: log a structured warning and return null.
147
- *
148
137
  * @remarks
149
138
  * NFS / SMB caveat: O_EXCL is not guaranteed atomic on network filesystems.
150
139
  * This implementation is reliable on local POSIX filesystems (APFS, HFS+, ext4, tmpfs).
151
140
  */
152
141
  export async function acquireLock(projectPath, specId, opts) {
142
+ await scavengeStaleLockArtifacts(projectPath, specId);
153
143
  const path = lockPath(projectPath, specId);
154
144
  const now = Date.now();
155
145
  const sessionId = resolveSessionId(opts);
@@ -236,6 +226,18 @@ export async function releaseLock(handle) {
236
226
  }
237
227
  throw err;
238
228
  }
229
+ const dir = dirname(path);
230
+ const base = basename(path);
231
+ const backups = await readdir(dir).catch(() => []);
232
+ await Promise.all(backups
233
+ .filter((entry) => entry.startsWith(`${base}.bak.`))
234
+ .map(async (entry) => {
235
+ const backupPath = join(dir, entry);
236
+ const backup = await readValidatedLockFile(backupPath);
237
+ if (backup?.metadata.sessionId === handle.metadata.sessionId) {
238
+ await unlink(backupPath).catch(() => undefined);
239
+ }
240
+ }));
239
241
  }
240
242
  /**
241
243
  * Read the current lockfile state for a spec.
@@ -259,7 +261,7 @@ export async function listActiveCrossProcessLocks(projectPath) {
259
261
  if (!existsSync(dir)) {
260
262
  return [];
261
263
  }
262
- const { readdir } = await import('node:fs/promises');
264
+ await scavengeStaleLockArtifacts(projectPath);
263
265
  let entries;
264
266
  try {
265
267
  entries = await readdir(dir);