@planu/cli 5.3.52 → 5.3.56

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 +97 -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 +1 -1
  40. package/planu-plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,100 @@
1
+ ## [5.3.56] - 2026-08-25
2
+
3
+ ### Bug Fixes
4
+ - fix(tests): anchor the revert-proof fixture to a pushed pre-guard tag
5
+ - fix(release): name the failing command and its real termination cause
6
+ - fix(release): name the blocking effect and fail closed on a broken tag lookup
7
+ - fix(release): re-anchor a superseded recovery ledger instead of dead-ending
8
+ - fix(worktree): reclaim content-equivalent worktrees and read the canonical branch
9
+
10
+ ### Chores
11
+ - chore(planu): close SPEC-1626
12
+ - chore(planu): close SPEC-1630
13
+ - chore(planu): close SPEC-1625
14
+ - chore(planu): close SPEC-1624, file SPEC-1629 transition-log rotation
15
+ - chore(planu): file SPEC-1625 and SPEC-1626 release-pipeline dogfood bugs
16
+
17
+
18
+ ## [5.3.55] - 2026-08-25
19
+
20
+ ### Bug Fixes
21
+ - fix(update-status): classify git-status degradation in spec artifact guard
22
+ - fix(worktree): drop only proper ancestors when eliminating base candidates
23
+ - fix(challenge): stop telling users to record accepted-risk evidence
24
+ - fix(worktree): validate worktree base against the canonical branch
25
+ - fix(gates): surface schema blockers in dod-gate rejections
26
+ - fix(contradiction-checker): close Path B on the criterion, not the scope
27
+ - fix(cli): add package-handoff command and honest EISDIR blocker
28
+ - fix(contradiction-checker): require predicate-position action assertion
29
+ - fix(spec-format): anchor single-line GIVEN/WHEN/THEN criterion regex
30
+ - fix(reconciliation): refresh stored title on the forward reconcile route
31
+ - fix(challenge-spec): stop reading a negated word as risk acceptance
32
+ - fix(cli): forward SDD evidence flags from spec status to the canonical mapping
33
+ - fix(update-status): refresh stored title from rewritten frontmatter
34
+ - fix(challenge): close resolution bullets on blank line
35
+ - fix(challenge): match resolution evidence against finding text
36
+ - fix(challenge): scope networkApi to a proximity-guarded bare API token
37
+ - fix(storage): stream transition-log reads to survive oversized lines
38
+ - fix(challenge): stop affirming capabilities from finding-field narration
39
+ - fix(update-status): discard a spec whose artifact is absent
40
+ - fix(validate): report unresolvable test links as unverifiable, not missing
41
+ - fix(update-status): drop the narrative JSDoc from the spec-artifact gate
42
+ - fix(update-status): refuse implementing transition on uncommitted spec.md
43
+ - fix(update-status): name the challenge-resolution ingress in gate blockers
44
+
45
+ ### Refactoring
46
+ - refactor(contradiction-checker): drop rationale comments in favor of names
47
+
48
+ ### Chores
49
+ - chore(planu): file SPEC-1624, clear integrated worktrees
50
+ - chore(planu): refresh session context
51
+ - chore(planu): refresh session context and release ledger
52
+
53
+
54
+ ## [5.3.54] - 2026-08-25
55
+
56
+ ### Bug Fixes
57
+ - fix(worktree): drop only proper ancestors when eliminating base candidates
58
+ - fix(challenge): stop telling users to record accepted-risk evidence
59
+ - fix(worktree): validate worktree base against the canonical branch
60
+ - fix(gates): surface schema blockers in dod-gate rejections
61
+ - fix(contradiction-checker): close Path B on the criterion, not the scope
62
+ - fix(cli): add package-handoff command and honest EISDIR blocker
63
+ - fix(contradiction-checker): require predicate-position action assertion
64
+ - fix(spec-format): anchor single-line GIVEN/WHEN/THEN criterion regex
65
+ - fix(reconciliation): refresh stored title on the forward reconcile route
66
+ - fix(challenge-spec): stop reading a negated word as risk acceptance
67
+ - fix(cli): forward SDD evidence flags from spec status to the canonical mapping
68
+ - fix(update-status): refresh stored title from rewritten frontmatter
69
+ - fix(challenge): close resolution bullets on blank line
70
+ - fix(challenge): match resolution evidence against finding text
71
+ - fix(challenge): scope networkApi to a proximity-guarded bare API token
72
+ - fix(storage): stream transition-log reads to survive oversized lines
73
+ - fix(challenge): stop affirming capabilities from finding-field narration
74
+ - fix(update-status): discard a spec whose artifact is absent
75
+ - fix(validate): report unresolvable test links as unverifiable, not missing
76
+ - fix(update-status): drop the narrative JSDoc from the spec-artifact gate
77
+ - fix(update-status): refuse implementing transition on uncommitted spec.md
78
+ - fix(update-status): name the challenge-resolution ingress in gate blockers
79
+
80
+ ### Refactoring
81
+ - refactor(contradiction-checker): drop rationale comments in favor of names
82
+
83
+ ### Chores
84
+ - chore(planu): file SPEC-1624, clear integrated worktrees
85
+ - chore(planu): refresh session context
86
+ - chore(planu): refresh session context and release ledger
87
+
88
+
89
+ ## [5.3.53] - 2026-08-25
90
+
91
+ ### Bug Fixes
92
+ - fix(update-status): name the challenge-resolution ingress in gate blockers
93
+
94
+ ### Chores
95
+ - chore(planu): refresh session context and release ledger
96
+
97
+
1
98
  ## [5.3.52] - 2026-08-25
2
99
 
3
100
  ### Bug Fixes
@@ -0,0 +1,3 @@
1
+ import type { CliCommand } from '../../types/cli.js';
2
+ export declare const packageHandoffCommand: CliCommand;
3
+ //# sourceMappingURL=package-handoff.d.ts.map
@@ -0,0 +1,43 @@
1
+ // cli/commands/package-handoff.ts — planu package-handoff <specId> (SPEC-1621)
2
+ import { parseArgs } from 'node:util';
3
+ import { resolve } from 'node:path';
4
+ import { handlePackageHandoff } from '../../tools/package-handoff.js';
5
+ import { formatToolResult } from '../formatter.js';
6
+ import { detectProjectId } from '../project-detector.js';
7
+ import { red } from '../colors.js';
8
+ export const packageHandoffCommand = {
9
+ name: 'package-handoff',
10
+ description: 'Generate the operational handoff package artifact for a spec',
11
+ usage: 'planu package-handoff <specId> [--project-path .] [--project-id ID]',
12
+ async run(args, flags) {
13
+ const { values, positionals } = parseArgs({
14
+ args,
15
+ options: {
16
+ 'project-id': { type: 'string' },
17
+ 'project-path': { type: 'string' },
18
+ },
19
+ strict: false,
20
+ allowPositionals: true,
21
+ });
22
+ const specId = positionals[0];
23
+ if (!specId) {
24
+ process.stderr.write(`${red('Error:')} Spec ID is required.\nUsage: ${packageHandoffCommand.usage}\n`);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ const projectPath = values['project-path'];
29
+ const projectId = values['project-id'] ??
30
+ detectProjectId(projectPath ? resolve(projectPath) : undefined);
31
+ const result = await handlePackageHandoff({ projectId, specId });
32
+ if (result.isError) {
33
+ process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
34
+ process.exitCode = 1;
35
+ return;
36
+ }
37
+ const output = formatToolResult(result, flags);
38
+ if (output) {
39
+ process.stdout.write(output + '\n');
40
+ }
41
+ },
42
+ };
43
+ //# sourceMappingURL=package-handoff.js.map
@@ -16,6 +16,7 @@ import { handleValidate } from '../../tools/validate.js';
16
16
  import { formatToolResult } from '../formatter.js';
17
17
  import { detectProjectId } from '../project-detector.js';
18
18
  import { bold, cyan, green, red, dim } from '../colors.js';
19
+ import { SINGLE_STATUS_PARSE_OPTIONS, buildSingleStatusInput } from './status.js';
19
20
  // ---------------------------------------------------------------------------
20
21
  // Helpers
21
22
  // ---------------------------------------------------------------------------
@@ -208,15 +209,12 @@ async function runShow(args, flags) {
208
209
  // ---------------------------------------------------------------------------
209
210
  // Subcommand: spec status
210
211
  // ---------------------------------------------------------------------------
211
- // eslint-disable-next-line complexity -- batch done requires an explicit pre-resolution fail-closed branch
212
212
  async function runStatus(args, flags) {
213
213
  const { positionals, values } = parseArgs({
214
214
  args,
215
215
  options: {
216
- 'project-id': { type: 'string' },
217
- notes: { type: 'string', short: 'n' },
216
+ ...SINGLE_STATUS_PARSE_OPTIONS,
218
217
  batch: { type: 'boolean' },
219
- set: { type: 'string', short: 's' },
220
218
  'implementation-review-digest': { type: 'string' },
221
219
  },
222
220
  strict: false,
@@ -257,10 +255,7 @@ async function runStatus(args, flags) {
257
255
  return;
258
256
  }
259
257
  const result = await handleUpdateStatus({
260
- specId,
261
- projectId,
262
- status: status,
263
- reviewNotes: values.notes ?? undefined,
258
+ ...buildSingleStatusInput(values, specId, projectId, status),
264
259
  implementationReviewDigest: values['implementation-review-digest'] ?? undefined,
265
260
  });
266
261
  if (result.isError) {
@@ -1,3 +1,54 @@
1
- import type { CliCommand } from '../../types/cli.js';
1
+ import type { CliCommand, StatusCommandValues } from '../../types/cli.js';
2
+ import type { UpdateStatusInput } from '../../types/spec/inputs.js';
2
3
  export declare const statusCommand: CliCommand;
4
+ export declare const SINGLE_STATUS_PARSE_OPTIONS: {
5
+ readonly set: {
6
+ readonly type: 'string';
7
+ readonly short: 's';
8
+ };
9
+ readonly 'project-id': {
10
+ readonly type: 'string';
11
+ };
12
+ readonly 'project-path': {
13
+ readonly type: 'string';
14
+ };
15
+ readonly notes: {
16
+ readonly type: 'string';
17
+ readonly short: 'n';
18
+ };
19
+ readonly 'model-id': {
20
+ readonly type: 'string';
21
+ };
22
+ readonly 'model-tier-used': {
23
+ readonly type: 'string';
24
+ };
25
+ readonly 'context-hash': {
26
+ readonly type: 'string';
27
+ };
28
+ readonly 'handoff-path': {
29
+ readonly type: 'string';
30
+ };
31
+ readonly 'handoff-artifact-id': {
32
+ readonly type: 'string';
33
+ };
34
+ readonly 'reviewed-by': {
35
+ readonly type: 'string';
36
+ };
37
+ readonly 'arbitrated-by': {
38
+ readonly type: 'string';
39
+ };
40
+ readonly reason: {
41
+ readonly type: 'string';
42
+ };
43
+ readonly force: {
44
+ readonly type: 'boolean';
45
+ };
46
+ readonly 'force-status': {
47
+ readonly type: 'boolean';
48
+ };
49
+ readonly 'force-status-reason': {
50
+ readonly type: 'string';
51
+ };
52
+ };
53
+ export declare function buildSingleStatusInput(values: StatusCommandValues, specId: string, projectId: string, status: string): UpdateStatusInput;
3
54
  //# sourceMappingURL=status.d.ts.map
@@ -37,26 +37,47 @@ export const statusCommand = {
37
37
  await runSingleStatus(specId, values, status, flags);
38
38
  },
39
39
  };
40
+ export const SINGLE_STATUS_PARSE_OPTIONS = {
41
+ set: { type: 'string', short: 's' },
42
+ 'project-id': { type: 'string' },
43
+ 'project-path': { type: 'string' },
44
+ notes: { type: 'string', short: 'n' },
45
+ 'model-id': { type: 'string' },
46
+ 'model-tier-used': { type: 'string' },
47
+ 'context-hash': { type: 'string' },
48
+ 'handoff-path': { type: 'string' },
49
+ 'handoff-artifact-id': { type: 'string' },
50
+ 'reviewed-by': { type: 'string' },
51
+ 'arbitrated-by': { type: 'string' },
52
+ reason: { type: 'string' },
53
+ force: { type: 'boolean' },
54
+ 'force-status': { type: 'boolean' },
55
+ 'force-status-reason': { type: 'string' },
56
+ };
57
+ export function buildSingleStatusInput(values, specId, projectId, status) {
58
+ return {
59
+ specId,
60
+ projectId,
61
+ projectPath: values['project-path'] ? resolve(values['project-path']) : undefined,
62
+ status: status,
63
+ reviewNotes: values.notes,
64
+ modelId: values['model-id'],
65
+ modelTierUsed: values['model-tier-used'],
66
+ contextHash: values['context-hash'],
67
+ handoffPath: values['handoff-path'],
68
+ handoffArtifactId: values['handoff-artifact-id'],
69
+ reviewedBy: values['reviewed-by'],
70
+ arbitratedBy: values['arbitrated-by'],
71
+ reason: values.reason,
72
+ force: values.force === true ? true : undefined,
73
+ forceStatus: values['force-status'] === true ? true : undefined,
74
+ forceStatusReason: values['force-status-reason'],
75
+ };
76
+ }
40
77
  function parseStatusArgs(args) {
41
78
  const parsed = parseArgs({
42
79
  args,
43
- options: {
44
- set: { type: 'string', short: 's' },
45
- 'project-id': { type: 'string' },
46
- 'project-path': { type: 'string' },
47
- notes: { type: 'string', short: 'n' },
48
- 'model-id': { type: 'string' },
49
- 'model-tier-used': { type: 'string' },
50
- 'context-hash': { type: 'string' },
51
- 'handoff-path': { type: 'string' },
52
- 'handoff-artifact-id': { type: 'string' },
53
- 'reviewed-by': { type: 'string' },
54
- 'arbitrated-by': { type: 'string' },
55
- reason: { type: 'string' },
56
- force: { type: 'boolean' },
57
- 'force-status': { type: 'boolean' },
58
- 'force-status-reason': { type: 'string' },
59
- },
80
+ options: SINGLE_STATUS_PARSE_OPTIONS,
60
81
  strict: false,
61
82
  allowPositionals: true,
62
83
  });
@@ -94,24 +115,7 @@ async function runBatchStatus(specIds, values, status, flags) {
94
115
  writeToolResult(result, flags, false);
95
116
  }
96
117
  async function runSingleStatus(specId, values, status, flags) {
97
- const result = await handleUpdateStatus({
98
- specId,
99
- projectId: values['project-id'] ?? detectProjectId(),
100
- projectPath: values['project-path'],
101
- status: status,
102
- reviewNotes: values.notes,
103
- modelId: values['model-id'],
104
- modelTierUsed: values['model-tier-used'],
105
- contextHash: values['context-hash'],
106
- handoffPath: values['handoff-path'],
107
- handoffArtifactId: values['handoff-artifact-id'],
108
- reviewedBy: values['reviewed-by'],
109
- arbitratedBy: values['arbitrated-by'],
110
- reason: values.reason,
111
- force: values.force === true ? true : undefined,
112
- forceStatus: values['force-status'] === true ? true : undefined,
113
- forceStatusReason: values['force-status-reason'],
114
- });
118
+ const result = await handleUpdateStatus(buildSingleStatusInput(values, specId, values['project-id'] ?? detectProjectId(), status));
115
119
  writeToolResult(result, flags, true);
116
120
  }
117
121
  function writeToolResult(result, flags, includeSuccessPrefix) {
@@ -4,7 +4,7 @@ import { PLANU_VERSION } from '../config/version.js';
4
4
  import { red, bold, cyan, dim } from './colors.js';
5
5
  // Lazy-load commands to avoid importing tool handlers when not needed
6
6
  async function loadCommands() {
7
- const [{ initCommand }, { createCommand }, { listCommand }, { statusCommand }, { estimateCommand }, { validateCommand }, { dashboardCommand }, { auditCommand }, { serveCommand }, { handoffCommand }, { watchCommand }, { installCommand }, { doctorCommand }, { uninstallCommand }, { specCommand }, { healthCommand }, { releaseCommand }, { storageCommand }, { inspectCommand }, { offlineCommand }, { jobCommand },] = await Promise.all([
7
+ const [{ initCommand }, { createCommand }, { listCommand }, { statusCommand }, { estimateCommand }, { validateCommand }, { dashboardCommand }, { auditCommand }, { serveCommand }, { handoffCommand }, { packageHandoffCommand }, { watchCommand }, { installCommand }, { doctorCommand }, { uninstallCommand }, { specCommand }, { healthCommand }, { releaseCommand }, { storageCommand }, { inspectCommand }, { offlineCommand }, { jobCommand },] = await Promise.all([
8
8
  import('./commands/init.js'),
9
9
  import('./commands/create.js'),
10
10
  import('./commands/list.js'),
@@ -15,6 +15,7 @@ async function loadCommands() {
15
15
  import('./commands/audit.js'),
16
16
  import('./commands/serve.js'),
17
17
  import('./commands/handoff.js'),
18
+ import('./commands/package-handoff.js'),
18
19
  import('./commands/watch.js'),
19
20
  import('./commands/install.js'),
20
21
  import('./commands/doctor.js'),
@@ -38,6 +39,7 @@ async function loadCommands() {
38
39
  auditCommand,
39
40
  serveCommand,
40
41
  handoffCommand,
42
+ packageHandoffCommand,
41
43
  watchCommand,
42
44
  installCommand,
43
45
  doctorCommand,
@@ -64,8 +66,9 @@ function printHelp(commands) {
64
66
  '',
65
67
  cyan('Commands:'),
66
68
  ];
69
+ const nameColumnWidth = Math.max(...[...commands.values()].map((cmd) => cmd.name.length));
67
70
  for (const cmd of commands.values()) {
68
- lines.push(` ${cmd.name.padEnd(14)} ${dim(cmd.description)}`);
71
+ lines.push(` ${cmd.name.padEnd(nameColumnWidth)} ${dim(cmd.description)}`);
69
72
  }
70
73
  lines.push('');
71
74
  lines.push(cyan('Global options:'));
@@ -0,0 +1,2 @@
1
+ export declare function resolveCanonicalBranch(cwd: string): string;
2
+ //# sourceMappingURL=canonical-branch.d.ts.map
@@ -0,0 +1,31 @@
1
+ import { execSync } from 'node:child_process';
2
+ function runCmd(cmd, cwd) {
3
+ try {
4
+ const output = execSync(cmd, {
5
+ cwd,
6
+ encoding: 'utf-8',
7
+ stdio: ['pipe', 'pipe', 'pipe'],
8
+ }).trim();
9
+ return { output, exitCode: 0 };
10
+ }
11
+ catch (err) {
12
+ const error = err;
13
+ return {
14
+ output: (error.stdout ?? '') + (error.stderr ?? ''),
15
+ exitCode: error.status ?? 1,
16
+ };
17
+ }
18
+ }
19
+ export function resolveCanonicalBranch(cwd) {
20
+ const originHead = runCmd('git symbolic-ref --short refs/remotes/origin/HEAD', cwd);
21
+ if (originHead.exitCode === 0) {
22
+ const branch = originHead.output.replace(/^origin\//, '');
23
+ const verified = runCmd(`git rev-parse --verify ${branch}`, cwd);
24
+ if (verified.exitCode === 0) {
25
+ return branch;
26
+ }
27
+ }
28
+ const survivors = ['main', 'master', 'develop'].filter((branch) => runCmd(`git rev-parse --verify ${branch}`, cwd).exitCode === 0);
29
+ return survivors.length === 1 ? (survivors[0] ?? '') : '';
30
+ }
31
+ //# sourceMappingURL=canonical-branch.js.map
@@ -119,6 +119,7 @@ export declare const ValidationReportV1Schema: z.ZodObject<{
119
119
  pass: "pass";
120
120
  }>;
121
121
  evidence: z.ZodArray<z.ZodString>;
122
+ unverifiable: z.ZodOptional<z.ZodLiteral<true>>;
122
123
  }, z.core.$strip>>;
123
124
  }, z.core.$strip>>;
124
125
  minimalityReport: z.ZodOptional<z.ZodObject<{
@@ -276,6 +277,7 @@ export declare const ARTIFACT_SCHEMAS: {
276
277
  pass: "pass";
277
278
  }>;
278
279
  evidence: z.ZodArray<z.ZodString>;
280
+ unverifiable: z.ZodOptional<z.ZodLiteral<true>>;
279
281
  }, z.core.$strip>>;
280
282
  }, z.core.$strip>>;
281
283
  minimalityReport: z.ZodOptional<z.ZodObject<{
@@ -89,6 +89,7 @@ export const ValidationReportV1Schema = z.object({
89
89
  title: z.string(),
90
90
  verdict: z.enum(['pass', 'fail', 'missing']),
91
91
  evidence: z.array(z.string()),
92
+ unverifiable: z.literal(true).optional(),
92
93
  })),
93
94
  })
94
95
  .optional(),
@@ -46,7 +46,7 @@ export function buildCheckReadinessSummary(score, blockerCount) {
46
46
  export function buildChallengeSpecSummary(blockingScenarios, gateStatus, advisoryCount) {
47
47
  const list = describeBlockingScenarios(blockingScenarios);
48
48
  if (gateStatus === 'blocked-critical') {
49
- return `BLOCKED — ${String(blockingScenarios.length)} unresolved critical finding(s) must be resolved before this passes the challenge gate: ${list}. Record mitigation or accepted-risk evidence, then run challenge_spec again.`;
49
+ return `BLOCKED — ${String(blockingScenarios.length)} unresolved critical finding(s) must be resolved before this passes the challenge gate: ${list}. Record resolution evidence, then run challenge_spec again.`;
50
50
  }
51
51
  if (gateStatus === 'blocked-evidence' || gateStatus === 'refresh-required') {
52
52
  if (gateStatus === 'refresh-required') {
@@ -72,6 +72,9 @@ async function completeForwardReconciliation(args) {
72
72
  if (syncResult.warning) {
73
73
  throw new Error(`portable spec sync failed: ${syncResult.warning.reason}`);
74
74
  }
75
+ if (syncResult.title) {
76
+ await specStore.updateSpec(args.projectId, args.spec.id, { title: syncResult.title });
77
+ }
75
78
  next = { ...next, transitionId: args.transitionId, phase: 'spec-synced', auditPending: true };
76
79
  await args.persistReceipt(args.receiptPath, next);
77
80
  }
@@ -101,6 +101,26 @@ function splitClauses(text) {
101
101
  .filter(Boolean);
102
102
  }
103
103
  const AFFIRMATIVE_ACTION_VERBS = /\b(?:build(?:s|ing)?|chang(?:e|es|ing)|modif(?:y|ies|ying)|alter(?:s|ing)?|add(?:s|ing)?|introduc(?:e|es|ing)|implement(?:s|ing)?|enabl(?:e|es|ing)|expos(?:e|es|ing|ed)|mutat(?:e|es|ing)|rewrit(?:e|es|ing|ten)|remov(?:e|es|ing|ed)|relax(?:es|ing|ed)?|forc(?:e|es|ing)|suppress(?:es|ing|ed)?|weaken(?:s|ing|ed)?|degrad(?:e|es|ing|ed)|loosen(?:s|ing|ed)?|undermin(?:e|es|ing|ed)|bypass(?:es|ing|ed)?|disabl(?:e|es|ing|ed)|circumvent(?:s|ing|ed)?)\b/;
104
+ const DETERMINERS_AND_PREPOSITIONS_HEADING_A_NOUN_PHRASE = new Set([
105
+ 'a',
106
+ 'an',
107
+ 'the',
108
+ 'this',
109
+ 'these',
110
+ 'those',
111
+ 'in',
112
+ 'on',
113
+ 'at',
114
+ 'of',
115
+ 'into',
116
+ 'onto',
117
+ 'within',
118
+ 'per',
119
+ ]);
120
+ const QUOTED_OR_BACKTICKED_SPAN_RE = /`[^`\n]+`|"[^"\n]+"|'[^'\n]+'/g;
121
+ function stripQuotedSpans(text) {
122
+ return text.replace(QUOTED_OR_BACKTICKED_SPAN_RE, ' ');
123
+ }
104
124
  function assertsForbiddenAction(text) {
105
125
  return AFFIRMATIVE_ACTION_VERBS.test(normalize(text));
106
126
  }
@@ -157,7 +177,7 @@ function clauseMentionsScope(clause, outOfScopeItem) {
157
177
  */
158
178
  const ACTION_SCOPE_WINDOW = 4;
159
179
  function assertsActionNearScope(clause, outOfScopeItem) {
160
- const tokens = normalize(clause).split(' ');
180
+ const tokens = normalize(stripQuotedSpans(clause)).split(' ');
161
181
  const scopeKeywords = keywords(outOfScopeItem);
162
182
  const mentionCandidates = scopeKeywords.length > 0 ? scopeKeywords : [normalize(outOfScopeItem)];
163
183
  for (let i = 0; i < tokens.length; i++) {
@@ -165,6 +185,11 @@ function assertsActionNearScope(clause, outOfScopeItem) {
165
185
  if (!AFFIRMATIVE_ACTION_VERBS.test(token)) {
166
186
  continue;
167
187
  }
188
+ const preceder = i > 0 ? tokens[i - 1] : undefined;
189
+ if (preceder !== undefined &&
190
+ DETERMINERS_AND_PREPOSITIONS_HEADING_A_NOUN_PHRASE.has(preceder)) {
191
+ continue;
192
+ }
168
193
  const before = tokens.slice(Math.max(0, i - 6), i).join(' ');
169
194
  if (endsWithNegation(before)) {
170
195
  continue;
@@ -201,10 +226,9 @@ function contradicts(criterionText, outOfScopeItem) {
201
226
  }
202
227
  const scopeAssertsForbiddenAction = assertsForbiddenAction(outOfScopeItem);
203
228
  return clauses.some((clause) => clauseMatchesScope(clause, outOfScopeItem) &&
204
- (!scopeAssertsForbiddenAction ||
205
- (hasConditionalException
206
- ? assertsForbiddenAction(clause)
207
- : assertsActionNearScope(clause, outOfScopeItem))));
229
+ (hasConditionalException && scopeAssertsForbiddenAction
230
+ ? assertsForbiddenAction(clause)
231
+ : assertsActionNearScope(clause, outOfScopeItem)));
208
232
  }
209
233
  /**
210
234
  * Check acceptance criteria against the spec's outOfScope declarations.
@@ -121,8 +121,13 @@ async function contextCanBeReconstructed(params) {
121
121
  blockers.push('handoffPath exists but is too small to be an operational package.');
122
122
  }
123
123
  }
124
- catch {
125
- blockers.push('handoffPath is missing or unreadable.');
124
+ catch (error) {
125
+ if (error.code === 'EISDIR') {
126
+ blockers.push('handoffPath is a directory, not the handoff package file.');
127
+ }
128
+ else {
129
+ blockers.push('handoffPath is missing or unreadable.');
130
+ }
126
131
  }
127
132
  }
128
133
  else if (!params.handoffArtifactId) {
@@ -166,7 +166,7 @@ function extractCheckboxCriteria(description) {
166
166
  function extractGivenWhenThenCriteria(description) {
167
167
  const criteria = [];
168
168
  // Single-line: GIVEN ... WHEN ... THEN ...
169
- const singleLineRegex = /GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)/gi;
169
+ const singleLineRegex = /^[ \t]*(?:[-*>]\s*)?GIVEN\s+.+\s+WHEN\s+.+\s+THEN\s+(.+)$/gim;
170
170
  let match = singleLineRegex.exec(description);
171
171
  while (match) {
172
172
  const thenText = match[1]?.trim() ?? '';
@@ -2,10 +2,11 @@
2
2
  //
3
3
  // Finds specs in 'implementing' status that have been there for >N days and have
4
4
  // no progress.md file. These are candidates for "done but untransitioned".
5
- import { readFile, access, readdir } from 'node:fs/promises';
5
+ import { access, readdir } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import { constants } from 'node:fs';
8
- import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
8
+ import { hashProjectPath } from '../../storage/base-store.js';
9
+ import { readTransitionLog } from '../../storage/transition-log.js';
9
10
  // ---------------------------------------------------------------------------
10
11
  // Helpers
11
12
  // ---------------------------------------------------------------------------
@@ -19,69 +20,29 @@ async function fileExists(p) {
19
20
  }
20
21
  }
21
22
  async function getLastTransitionForSpec(projectId, specId) {
22
- const logPath = join(projectDataDir(projectId), 'transition-log.jsonl');
23
- try {
24
- const raw = await readFile(logPath, 'utf-8');
25
- const lines = raw.trim().split('\n').filter(Boolean);
26
- let lastAt = null;
27
- for (const line of lines) {
28
- try {
29
- const entry = JSON.parse(line);
30
- if (typeof entry === 'object' &&
31
- entry !== null &&
32
- 'specId' in entry &&
33
- entry.specId === specId) {
34
- const at = entry.timestamp;
35
- if (typeof at === 'string') {
36
- lastAt = at;
37
- }
38
- }
39
- }
40
- catch {
41
- // Skip malformed lines
42
- }
23
+ const entries = await readTransitionLog(projectId, specId);
24
+ let lastAt = null;
25
+ for (const entry of entries) {
26
+ if (typeof entry.timestamp === 'string') {
27
+ lastAt = entry.timestamp;
43
28
  }
44
- return lastAt;
45
- }
46
- catch {
47
- return null;
48
29
  }
30
+ return lastAt;
49
31
  }
50
32
  async function getDaysInImplementing(projectId, specId) {
51
- const logPath = join(projectDataDir(projectId), 'transition-log.jsonl');
52
- try {
53
- const raw = await readFile(logPath, 'utf-8');
54
- const lines = raw.trim().split('\n').filter(Boolean);
55
- let implementingAt = null;
56
- for (const line of lines) {
57
- try {
58
- const entry = JSON.parse(line);
59
- if (typeof entry === 'object' &&
60
- entry !== null &&
61
- 'specId' in entry &&
62
- 'to' in entry &&
63
- entry.specId === specId &&
64
- entry.to === 'implementing') {
65
- const at = entry.timestamp;
66
- if (typeof at === 'string') {
67
- implementingAt = at;
68
- }
69
- }
70
- }
71
- catch {
72
- // Skip
73
- }
74
- }
75
- if (implementingAt) {
76
- const then = new Date(implementingAt).getTime();
77
- const now = Date.now();
78
- return Math.floor((now - then) / (1000 * 60 * 60 * 24));
33
+ const entries = await readTransitionLog(projectId, specId);
34
+ let implementingAt = null;
35
+ for (const entry of entries) {
36
+ if (entry.to === 'implementing' && typeof entry.timestamp === 'string') {
37
+ implementingAt = entry.timestamp;
79
38
  }
80
39
  }
81
- catch {
82
- // Fall through
40
+ if (!implementingAt) {
41
+ return -1;
83
42
  }
84
- return -1;
43
+ const then = new Date(implementingAt).getTime();
44
+ const now = Date.now();
45
+ return Math.floor((now - then) / (1000 * 60 * 60 * 24));
85
46
  }
86
47
  // ---------------------------------------------------------------------------
87
48
  // Public API
@@ -26,6 +26,7 @@ const QUALIFIED_EVIDENCE_SUBJECT_RE = /^\s*(?:[-*+>]\s+|\d+[.)]\s+)?(?:(?:given|
26
26
  const CONTRACT_ANALYSIS_SUBJECT_RE = /^\s*(?:[-*+>]\s+|\d+[.)]\s+)?(?:(?:given|when|then|and)\s+)?(?:(?:the|this|that|a|an|any|each|our|existing|current)\s+)?(?:[\p{L}\p{N}_-]+\s+){0,2}contract\s+(?:evidence|inference|requirements?)\b/iu;
27
27
  const CONTRACT_ANALYSIS_PREDICATE_RE = /\b(?:(?:is|are)\s+(?:inferred|expected|selected|classified|matched|detected|discussed|reported|recorded)|expects?|records?|describes?)\b|\b(?:detector|classifier|matcher)\s+evidence\b|\b(?:positive[ -]?control|expected\s+absence|meta-analysis(?:-only)?)\b/iu;
28
28
  const META_ANALYSIS_TOOL_OUTPUT_RE = /\b(?:challenge[_\s]spec|challenge\s+tool)\b[^.!?\n]{0,120}?\bcall\b[^.!?\n]{0,120}?\b(?:emitted|returned|produced|reported|surfaced)\b[^.!?\n]{0,120}?\bscenarios?\b/iu;
29
+ const CHALLENGE_FINDING_FIELD_RE = /\b(?:requiredHandling|currentHandling|dataConsistency|userExperience|relevanceScore)\b/u;
29
30
  function stripExcludedMarkdownSections(text) {
30
31
  const lines = text.split('\n');
31
32
  const kept = [];
@@ -73,7 +74,8 @@ export function stripMetaAnalysisText(text) {
73
74
  !EXPECTED_META_ANALYSIS_RE.test(clause) &&
74
75
  !QUALIFIED_EVIDENCE_SUBJECT_RE.test(clause) &&
75
76
  !(CONTRACT_ANALYSIS_SUBJECT_RE.test(clause) && CONTRACT_ANALYSIS_PREDICATE_RE.test(clause)) &&
76
- !META_ANALYSIS_TOOL_OUTPUT_RE.test(clause))
77
+ !META_ANALYSIS_TOOL_OUTPUT_RE.test(clause) &&
78
+ !CHALLENGE_FINDING_FIELD_RE.test(clause))
77
79
  .join('\n');
78
80
  }
79
81
  function clauseBefore(text, index) {