@planu/cli 5.3.51 → 5.3.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/dist/cli/commands/package-handoff.d.ts +3 -0
  3. package/dist/cli/commands/package-handoff.js +43 -0
  4. package/dist/cli/commands/spec.js +3 -8
  5. package/dist/cli/commands/status.d.ts +52 -1
  6. package/dist/cli/commands/status.js +39 -35
  7. package/dist/cli/router.js +5 -2
  8. package/dist/engine/git/canonical-branch.d.ts +2 -0
  9. package/dist/engine/git/canonical-branch.js +31 -0
  10. package/dist/engine/handoff-artifacts/schemas.d.ts +2 -0
  11. package/dist/engine/handoff-artifacts/schemas.js +1 -0
  12. package/dist/engine/human-summary.js +1 -1
  13. package/dist/engine/lifecycle-reconciliation.js +3 -0
  14. package/dist/engine/scope-boundaries/contradiction-checker.js +29 -5
  15. package/dist/engine/sdd-model-routing.js +7 -2
  16. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  17. package/dist/engine/staleness/stale-implementing.js +19 -58
  18. package/dist/engine/text-signal-boundaries.js +3 -1
  19. package/dist/engine/validator/spec-compliance-runner.d.ts +1 -0
  20. package/dist/engine/validator/spec-compliance-runner.js +13 -6
  21. package/dist/engine/validator/validation-report-writer.js +1 -0
  22. package/dist/engine/workflow-validator/worktree-protocol.js +45 -36
  23. package/dist/storage/transition-log.d.ts +2 -14
  24. package/dist/storage/transition-log.js +84 -45
  25. package/dist/tools/challenge-spec/challenge-report.js +42 -11
  26. package/dist/tools/challenge-spec/scenarios-utils.js +9 -2
  27. package/dist/tools/generate-orchestration-script.js +2 -1
  28. package/dist/tools/register-platform-tools/design-stack-tools.js +1 -1
  29. package/dist/tools/suggest-tooling/orchestration-generator.js +2 -2
  30. package/dist/tools/sync-spec-state-handler.js +20 -7
  31. package/dist/tools/update-status/dod-gates.js +36 -6
  32. package/dist/tools/update-status/file-sync.d.ts +1 -0
  33. package/dist/tools/update-status/file-sync.js +45 -13
  34. package/dist/tools/update-status/index.js +12 -1
  35. package/dist/tools/update-status/transition-guard.d.ts +1 -0
  36. package/dist/tools/update-status/transition-guard.js +50 -4
  37. package/dist/tools/validate.js +5 -2
  38. package/dist/types/handoff-artifacts.d.ts +2 -0
  39. package/package.json +20 -20
  40. package/planu-plugin.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,92 @@
1
+ ## [5.3.55] - 2026-08-25
2
+
3
+ ### Bug Fixes
4
+ - fix(update-status): classify git-status degradation in spec artifact guard
5
+ - fix(worktree): drop only proper ancestors when eliminating base candidates
6
+ - fix(challenge): stop telling users to record accepted-risk evidence
7
+ - fix(worktree): validate worktree base against the canonical branch
8
+ - fix(gates): surface schema blockers in dod-gate rejections
9
+ - fix(contradiction-checker): close Path B on the criterion, not the scope
10
+ - fix(cli): add package-handoff command and honest EISDIR blocker
11
+ - fix(contradiction-checker): require predicate-position action assertion
12
+ - fix(spec-format): anchor single-line GIVEN/WHEN/THEN criterion regex
13
+ - fix(reconciliation): refresh stored title on the forward reconcile route
14
+ - fix(challenge-spec): stop reading a negated word as risk acceptance
15
+ - fix(cli): forward SDD evidence flags from spec status to the canonical mapping
16
+ - fix(update-status): refresh stored title from rewritten frontmatter
17
+ - fix(challenge): close resolution bullets on blank line
18
+ - fix(challenge): match resolution evidence against finding text
19
+ - fix(challenge): scope networkApi to a proximity-guarded bare API token
20
+ - fix(storage): stream transition-log reads to survive oversized lines
21
+ - fix(challenge): stop affirming capabilities from finding-field narration
22
+ - fix(update-status): discard a spec whose artifact is absent
23
+ - fix(validate): report unresolvable test links as unverifiable, not missing
24
+ - fix(update-status): drop the narrative JSDoc from the spec-artifact gate
25
+ - fix(update-status): refuse implementing transition on uncommitted spec.md
26
+ - fix(update-status): name the challenge-resolution ingress in gate blockers
27
+
28
+ ### Refactoring
29
+ - refactor(contradiction-checker): drop rationale comments in favor of names
30
+
31
+ ### Chores
32
+ - chore(planu): file SPEC-1624, clear integrated worktrees
33
+ - chore(planu): refresh session context
34
+ - chore(planu): refresh session context and release ledger
35
+
36
+
37
+ ## [5.3.54] - 2026-08-25
38
+
39
+ ### Bug Fixes
40
+ - fix(worktree): drop only proper ancestors when eliminating base candidates
41
+ - fix(challenge): stop telling users to record accepted-risk evidence
42
+ - fix(worktree): validate worktree base against the canonical branch
43
+ - fix(gates): surface schema blockers in dod-gate rejections
44
+ - fix(contradiction-checker): close Path B on the criterion, not the scope
45
+ - fix(cli): add package-handoff command and honest EISDIR blocker
46
+ - fix(contradiction-checker): require predicate-position action assertion
47
+ - fix(spec-format): anchor single-line GIVEN/WHEN/THEN criterion regex
48
+ - fix(reconciliation): refresh stored title on the forward reconcile route
49
+ - fix(challenge-spec): stop reading a negated word as risk acceptance
50
+ - fix(cli): forward SDD evidence flags from spec status to the canonical mapping
51
+ - fix(update-status): refresh stored title from rewritten frontmatter
52
+ - fix(challenge): close resolution bullets on blank line
53
+ - fix(challenge): match resolution evidence against finding text
54
+ - fix(challenge): scope networkApi to a proximity-guarded bare API token
55
+ - fix(storage): stream transition-log reads to survive oversized lines
56
+ - fix(challenge): stop affirming capabilities from finding-field narration
57
+ - fix(update-status): discard a spec whose artifact is absent
58
+ - fix(validate): report unresolvable test links as unverifiable, not missing
59
+ - fix(update-status): drop the narrative JSDoc from the spec-artifact gate
60
+ - fix(update-status): refuse implementing transition on uncommitted spec.md
61
+ - fix(update-status): name the challenge-resolution ingress in gate blockers
62
+
63
+ ### Refactoring
64
+ - refactor(contradiction-checker): drop rationale comments in favor of names
65
+
66
+ ### Chores
67
+ - chore(planu): file SPEC-1624, clear integrated worktrees
68
+ - chore(planu): refresh session context
69
+ - chore(planu): refresh session context and release ledger
70
+
71
+
72
+ ## [5.3.53] - 2026-08-25
73
+
74
+ ### Bug Fixes
75
+ - fix(update-status): name the challenge-resolution ingress in gate blockers
76
+
77
+ ### Chores
78
+ - chore(planu): refresh session context and release ledger
79
+
80
+
81
+ ## [5.3.52] - 2026-08-25
82
+
83
+ ### Bug Fixes
84
+ - fix(deps): align hono override with the upgraded dependency
85
+
86
+ ### Chores
87
+ - chore(deps): batch patch and minor updates, hold stryker at 9.6.1
88
+
89
+
1
90
  ## [5.3.51] - 2026-08-25
2
91
 
3
92
  ### 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) {