@planu/cli 5.5.3 → 5.7.0

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 (118) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/.planu-build.json +1 -1
  3. package/dist/cli/commands/telemetry.d.ts +3 -0
  4. package/dist/cli/commands/telemetry.js +118 -0
  5. package/dist/cli/router.js +3 -1
  6. package/dist/config/environment-schema.json +14 -0
  7. package/dist/engine/autopilot/bootstrap.js +1 -1
  8. package/dist/engine/cascade-hooks/core/append-releases.js +22 -12
  9. package/dist/engine/contradiction-detector.d.ts +2 -1
  10. package/dist/engine/contradiction-detector.js +215 -0
  11. package/dist/engine/detection-utils.d.ts +1 -0
  12. package/dist/engine/detection-utils.js +33 -0
  13. package/dist/engine/doc-generator/portal/index.d.ts +1 -1
  14. package/dist/engine/doc-generator/portal/index.js +1 -1
  15. package/dist/engine/doc-generator/portal/portal-regenerator.d.ts +8 -3
  16. package/dist/engine/doc-generator/portal/portal-regenerator.js +16 -7
  17. package/dist/engine/evidence-gates/evidence-autofill.d.ts +1 -1
  18. package/dist/engine/evidence-gates/evidence-autofill.js +1 -1
  19. package/dist/engine/framework-detector.js +8 -6
  20. package/dist/engine/handoff-artifacts/schemas.js +4 -0
  21. package/dist/engine/housekeeping/history-log.d.ts +1 -0
  22. package/dist/engine/housekeeping/history-log.js +58 -3
  23. package/dist/engine/housekeeping/index.d.ts +2 -1
  24. package/dist/engine/housekeeping/index.js +2 -1
  25. package/dist/engine/housekeeping/legacy-planu-demolisher.d.ts +3 -0
  26. package/dist/engine/housekeeping/legacy-planu-demolisher.js +164 -0
  27. package/dist/engine/housekeeping/runtime-residue-sweep.d.ts +9 -0
  28. package/dist/engine/housekeeping/runtime-residue-sweep.js +57 -0
  29. package/dist/engine/lifecycle-reconciliation.js +87 -40
  30. package/dist/engine/next-spec-resolver/orchestration-planner.js +1 -1
  31. package/dist/engine/next-spec-resolver/session-writer.js +1 -1
  32. package/dist/engine/project-graph/cache.js +23 -3
  33. package/dist/engine/readiness-checker.js +14 -2
  34. package/dist/engine/sdd-flow/checkpoints.js +29 -2
  35. package/dist/engine/session/checkpoint-writer.d.ts +1 -1
  36. package/dist/engine/session/checkpoint-writer.js +6 -5
  37. package/dist/engine/session-state/writer.js +4 -3
  38. package/dist/engine/spec-format/lean-spec-generator.js +1 -1
  39. package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +8 -1
  40. package/dist/engine/spec-migrator/planu-canonical-policy.js +14 -13
  41. package/dist/engine/spec-migrator/strict-planu-cleanup.js +28 -3
  42. package/dist/engine/telemetry/error-reporter.d.ts +9 -9
  43. package/dist/engine/telemetry/error-reporter.js +15 -34
  44. package/dist/engine/telemetry/event-envelope.d.ts +11 -0
  45. package/dist/engine/telemetry/event-envelope.js +124 -0
  46. package/dist/engine/telemetry/telemetry-client.d.ts +8 -1
  47. package/dist/engine/telemetry/telemetry-client.js +38 -20
  48. package/dist/engine/telemetry/telemetry-store.d.ts +15 -2
  49. package/dist/engine/telemetry/telemetry-store.js +73 -2
  50. package/dist/engine/universal-rules/rules/planu-release-policy.js +1 -1
  51. package/dist/engine/validator/spec-compliance-runner.d.ts +2 -1
  52. package/dist/engine/validator/spec-compliance-runner.js +123 -1
  53. package/dist/hosts/claude-code/ux/mcp-resources.js +7 -23
  54. package/dist/index.js +26 -0
  55. package/dist/resources/specs.js +12 -33
  56. package/dist/storage/current-project.d.ts +3 -0
  57. package/dist/storage/current-project.js +21 -0
  58. package/dist/storage/index.d.ts +1 -0
  59. package/dist/storage/index.js +1 -0
  60. package/dist/storage/migrations/canonical-storage.js +5 -0
  61. package/dist/storage/retention.d.ts +14 -0
  62. package/dist/storage/retention.js +279 -0
  63. package/dist/storage/spec-index.d.ts +23 -0
  64. package/dist/storage/spec-index.js +123 -0
  65. package/dist/storage/spec-store.d.ts +8 -3
  66. package/dist/storage/spec-store.js +76 -6
  67. package/dist/storage/storage-catalog.js +3 -3
  68. package/dist/storage/storage-layout.d.ts +8 -0
  69. package/dist/storage/storage-layout.js +9 -0
  70. package/dist/storage/transition-log.js +2 -0
  71. package/dist/tools/challenge-spec.js +35 -19
  72. package/dist/tools/create-spec.js +10 -0
  73. package/dist/tools/execute-sdd-flow.js +11 -2
  74. package/dist/tools/export-spec.js +2 -1
  75. package/dist/tools/force-status-analytics.js +2 -1
  76. package/dist/tools/generate-docs-site.js +2 -1
  77. package/dist/tools/generate-proposal.js +6 -2
  78. package/dist/tools/init-project/claude-md-generator.js +19 -2
  79. package/dist/tools/init-project/conventions-writer.d.ts +5 -2
  80. package/dist/tools/init-project/conventions-writer.js +18 -13
  81. package/dist/tools/init-project/git-setup.js +9 -0
  82. package/dist/tools/init-project/handler.js +9 -1
  83. package/dist/tools/init-project/legacy-planu.d.ts +2 -0
  84. package/dist/tools/init-project/legacy-planu.js +18 -0
  85. package/dist/tools/init-project/legacy-root-migration.d.ts +15 -0
  86. package/dist/tools/init-project/legacy-root-migration.js +213 -0
  87. package/dist/tools/init-project/runtime-residue.d.ts +2 -0
  88. package/dist/tools/init-project/runtime-residue.js +11 -0
  89. package/dist/tools/init-project/schedule-housekeeping.d.ts +2 -0
  90. package/dist/tools/init-project/schedule-housekeeping.js +8 -0
  91. package/dist/tools/reconcile-spec.js +29 -2
  92. package/dist/tools/register-spec-tools/analysis-tools.d.ts +7 -0
  93. package/dist/tools/register-spec-tools/analysis-tools.js +13 -1
  94. package/dist/tools/safe-handler.js +6 -12
  95. package/dist/tools/session-checkpoint.js +1 -1
  96. package/dist/tools/update-status/index.js +7 -1
  97. package/dist/tools/update-status-actions.d.ts +7 -1
  98. package/dist/tools/update-status-actions.js +10 -2
  99. package/dist/types/handoff-artifacts.d.ts +1 -0
  100. package/dist/types/housekeeping.d.ts +38 -0
  101. package/dist/types/housekeeping.js +0 -1
  102. package/dist/types/index.d.ts +1 -0
  103. package/dist/types/index.js +1 -0
  104. package/dist/types/retention.d.ts +12 -0
  105. package/dist/types/retention.js +3 -0
  106. package/dist/types/scope.d.ts +23 -0
  107. package/dist/types/spec/core.d.ts +7 -0
  108. package/dist/types/spec/index-cache.d.ts +25 -0
  109. package/dist/types/spec/index-cache.js +3 -0
  110. package/dist/types/spec/index.d.ts +1 -0
  111. package/dist/types/spec/index.js +1 -0
  112. package/dist/types/spec/inputs.d.ts +9 -0
  113. package/dist/types/spec-format.d.ts +1 -0
  114. package/dist/types/telemetry.d.ts +39 -1
  115. package/dist/types/validation-evidence.d.ts +6 -0
  116. package/package.json +1 -1
  117. package/planu-plugin.json +1 -1
  118. package/scripts/lib/pending-release-file.mjs +20 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,61 @@
1
+ ## [5.7.0] - 2026-08-31
2
+
3
+ ### Features
4
+ - feat: registry-mapped ~/.planu demolisher and storage isolation guard (SPEC-1709)
5
+ - feat(lifecycle): SPEC-1703 declared architectural-premise drift routes reconcile_spec demotion
6
+ - feat: add done-drift missing-files compliance check (SPEC-1701)
7
+ - feat(telemetry): SPEC-1704 anonymous event envelope v1 and consent CLI
8
+
9
+ ### Bug Fixes
10
+ - fix: serialize prepublish-guard suite to avoid full-load spawn flake
11
+ - fix: declare telemetry and test-home env vars in schema and serialize lock-lease suite
12
+ - fix: reuse shared pathExists helper in legacy planu demolisher (SPEC-1709)
13
+ - fix: harden legacy planu demolisher (symlinks, canonical root) SPEC-1709
14
+ - fix: enforce mcp trust boundary and bind drift source into receipt digest (SPEC-1703)
15
+ - fix: exclude globs and directory refs from done-drift check, wire early return (SPEC-1701)
16
+ - fix(telemetry): close legacy properties, validate ids, truncate show output (SPEC-1704)
17
+ - fix(gates): destination-aware premise matching and tool-level AC1 coverage (SPEC-1702 review fixes)
18
+ - fix(gates): SPEC-1702 cross-spec premise contradiction detection
19
+
20
+
21
+ ## [5.6.0] - 2026-08-31
22
+
23
+ ### Features
24
+ - feat(init-project): regenerate conventions.md from folded conventions.json (SPEC-1699)
25
+ - feat(init-project): fold legacy planu/ root artifacts on update (SPEC-1699)
26
+ - feat(init-project): gitignore legacy planu/ root files (SPEC-1699)
27
+ - feat(create-spec): refresh the regenerable spec index after writing spec.md (SPEC-1699)
28
+ - feat(storage): add regenerable spec.md index (SPEC-1699)
29
+
30
+ ### Bug Fixes
31
+ - fix(test): reconcile suites with SPEC-1699 runtime relocation and serialize frozen-lockfile suite
32
+ - fix(storage): classify retention and current-project catch degradations
33
+ - fix(release): require the exact tsgo package dir, not any node_modules content
34
+ - fix(release): drop CI=true from frozen-lockfile install, probe tsgo native package
35
+ - fix(SPEC-1696): accept inline YAML arrays for scenario tests in frontmatter
36
+ - fix(storage): close retention review gaps from Codex CHANGES_REQUIRED
37
+ - fix(storage): enforce retention budgets on runtime storage writers
38
+ - fix(SPEC-1694): report the actual test runner instead of Unknown
39
+ - fix(SPEC-1694): promote framework/database claims only from runtime dependencies
40
+ - fix: parse ordered-list markers in BDD criteria extraction (SPEC-1688)
41
+ - fix: repair release-gate collateral fallout from SPEC-1699 fold (blocker 6 sweep)
42
+ - fix: release.sh repoints version marker to planu/project.json (SPEC-1699 blocker 6)
43
+ - fix: reconcile-release-pending falls back to legacy pending.json (SPEC-1699 blocker 4)
44
+ - fix: legacy-root-migration idempotency and fail-safe malformed handling (SPEC-1699 blocker 3)
45
+ - fix: append-releases fails closed on ledger errors (SPEC-1699 blocker 5)
46
+ - fix: verify spec.md digest at the spec-store read choke point (SPEC-1699 blocker 2)
47
+ - fix(housekeeping): protect planu/.runtime as the canonical runtime home (SPEC-1699)
48
+ - fix: address implementation-review blockers for SPEC-1695 runtime relocation
49
+ - fix: reroute default proposal/export/docs-site outputs to planu/.runtime (SPEC-1695)
50
+ - fix: relocate runtime artifacts to planu/.runtime (SPEC-1695)
51
+
52
+ ### Refactoring
53
+ - refactor(release): fold pending release ledger into planu/project.json (SPEC-1699)
54
+ - refactor: relocate session.json and session-context.md under planu/.runtime (SPEC-1699)
55
+ - refactor: route spec resources through the current-project spec index (SPEC-1699)
56
+ - refactor: canonical planu/ set shrinks to project.json + specs/ (SPEC-1699)
57
+
58
+
1
59
  ## [5.5.3] - 2026-08-30
2
60
 
3
61
  ### Bug Fixes
@@ -1 +1 @@
1
- {"schemaVersion":1,"commit":"5060255598e14cab792b6b5b0d75a077f23fe018"}
1
+ {"schemaVersion":1,"commit":"e145c9f04adb18813ddff699db636c390b20a776"}
@@ -0,0 +1,3 @@
1
+ import type { CliCommand } from '../../types/cli.js';
2
+ export declare const telemetryCommand: CliCommand;
3
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,118 @@
1
+ // cli/commands/telemetry.ts — planu telemetry status|enable|disable|show (SPEC-1704)
2
+ import { isTelemetryEnabled, readTelemetryConfig, enableTelemetry, disableTelemetry, } from '../../engine/telemetry/telemetry-store.js';
3
+ import { buildTelemetryEnvelope, TELEMETRY_SESSION_ID, } from '../../engine/telemetry/event-envelope.js';
4
+ import { bold, dim, green, red } from '../colors.js';
5
+ const NOTICE = 'Planu telemetry, when enabled, sends anonymous usage events (tool name, result, duration ' +
6
+ 'bucket, operating system, Node major version, Planu version) tagged with a random per-install ' +
7
+ 'id. It never sends prompts, tool arguments or results, spec content, source code, file paths, ' +
8
+ 'usernames, hostnames, repository names, environment variables, tokens, or raw stack traces. ' +
9
+ 'Run `planu telemetry disable` to opt out anytime.';
10
+ const TRUNCATED_ID_LENGTH = 8;
11
+ const SAMPLE_INSTALLATION_ID = '00000000-0000-4000-8000-000000000000';
12
+ const SAMPLE_DURATION_MS = 450;
13
+ function truncateId(id) {
14
+ if (!id) {
15
+ return '(none)';
16
+ }
17
+ return `${id.slice(0, TRUNCATED_ID_LENGTH)}…`;
18
+ }
19
+ function activeSuppressionFlags() {
20
+ const active = [];
21
+ if (process.env.PLANU_TELEMETRY_DISABLED === '1') {
22
+ active.push('PLANU_TELEMETRY_DISABLED=1');
23
+ }
24
+ if (process.env.DO_NOT_TRACK === '1') {
25
+ active.push('DO_NOT_TRACK=1');
26
+ }
27
+ if (process.env.CI === 'true') {
28
+ active.push('CI=true');
29
+ }
30
+ if (process.env.PLANU_TELEMETRY === 'off') {
31
+ active.push('PLANU_TELEMETRY=off');
32
+ }
33
+ return active;
34
+ }
35
+ async function runStatus(flags) {
36
+ const enabled = await isTelemetryEnabled();
37
+ const config = await readTelemetryConfig();
38
+ const suppressionFlagsActive = activeSuppressionFlags();
39
+ if (flags?.json) {
40
+ process.stdout.write(JSON.stringify({
41
+ enabled,
42
+ consentVersion: config?.consentVersion,
43
+ anonymousInstallationId: truncateId(config?.anonymousInstallationId),
44
+ suppressionFlagsActive,
45
+ }) + '\n');
46
+ return;
47
+ }
48
+ const lines = [
49
+ `${bold('Telemetry:')} ${enabled ? green('enabled') : red('disabled')}`,
50
+ `${dim('Installation id:')} ${truncateId(config?.anonymousInstallationId)}`,
51
+ ];
52
+ if (suppressionFlagsActive.length > 0) {
53
+ lines.push(`${dim('Suppressed by:')} ${suppressionFlagsActive.join(', ')}`);
54
+ }
55
+ process.stdout.write(lines.join('\n') + '\n');
56
+ }
57
+ async function runEnable(flags) {
58
+ const config = await enableTelemetry();
59
+ if (flags?.json) {
60
+ process.stdout.write(JSON.stringify({
61
+ enabled: true,
62
+ anonymousInstallationId: truncateId(config.anonymousInstallationId),
63
+ }) + '\n');
64
+ return;
65
+ }
66
+ process.stdout.write(`${green('Telemetry enabled.')} ${NOTICE}\n`);
67
+ }
68
+ async function runDisable(flags) {
69
+ await disableTelemetry();
70
+ if (flags?.json) {
71
+ process.stdout.write(JSON.stringify({ enabled: false }) + '\n');
72
+ return;
73
+ }
74
+ process.stdout.write(`${red('Telemetry disabled.')} No anonymous usage events will be sent.\n`);
75
+ }
76
+ async function runShow() {
77
+ const config = await readTelemetryConfig();
78
+ const envelope = buildTelemetryEnvelope('mcp_tool_completed', {
79
+ anonymousInstallationId: SAMPLE_INSTALLATION_ID,
80
+ sessionId: TELEMETRY_SESSION_ID,
81
+ toolName: 'planu_status',
82
+ result: 'success',
83
+ durationMs: SAMPLE_DURATION_MS,
84
+ });
85
+ process.stdout.write(`${dim('Current installation id:')} ${truncateId(config?.anonymousInstallationId)}\n`);
86
+ const displayEnvelope = {
87
+ ...envelope,
88
+ anonymousInstallationId: truncateId(envelope.anonymousInstallationId),
89
+ };
90
+ process.stdout.write(JSON.stringify(displayEnvelope, null, 2) + '\n');
91
+ }
92
+ export const telemetryCommand = {
93
+ name: 'telemetry',
94
+ description: 'Manage local telemetry consent (status | enable | disable | show)',
95
+ usage: 'planu telemetry <status|enable|disable|show> [--json]',
96
+ async run(args, flags) {
97
+ const subcommand = args[0] ?? 'status';
98
+ switch (subcommand) {
99
+ case 'status':
100
+ await runStatus(flags);
101
+ return;
102
+ case 'enable':
103
+ await runEnable(flags);
104
+ return;
105
+ case 'disable':
106
+ await runDisable(flags);
107
+ return;
108
+ case 'show':
109
+ await runShow();
110
+ return;
111
+ default:
112
+ process.stderr.write(`${red('Error:')} Unknown telemetry subcommand "${subcommand}"\n`);
113
+ process.stderr.write(`Usage: ${telemetryCommand.usage}\n`);
114
+ process.exitCode = 1;
115
+ }
116
+ },
117
+ };
118
+ //# sourceMappingURL=telemetry.js.map
@@ -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 }, { packageHandoffCommand }, { 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 }, { telemetryCommand },] = await Promise.all([
8
8
  import('./commands/init.js'),
9
9
  import('./commands/create.js'),
10
10
  import('./commands/list.js'),
@@ -27,6 +27,7 @@ async function loadCommands() {
27
27
  import('./commands/inspect.js'),
28
28
  import('./commands/offline.js'),
29
29
  import('./commands/job.js'),
30
+ import('./commands/telemetry.js'),
30
31
  ]);
31
32
  const cmds = [
32
33
  initCommand,
@@ -51,6 +52,7 @@ async function loadCommands() {
51
52
  inspectCommand,
52
53
  offlineCommand,
53
54
  jobCommand,
55
+ telemetryCommand,
54
56
  ];
55
57
  const map = new Map();
56
58
  for (const cmd of cmds) {
@@ -218,6 +218,13 @@
218
218
  "sensitivity": "private",
219
219
  "default": null
220
220
  },
221
+ {
222
+ "name": "PLANU_TELEMETRY_DISABLED",
223
+ "type": "boolean",
224
+ "scope": "runtime",
225
+ "sensitivity": "public",
226
+ "default": null
227
+ },
221
228
  {
222
229
  "name": "PLANU_TELEMETRY_ENDPOINT",
223
230
  "type": "string",
@@ -232,6 +239,13 @@
232
239
  "sensitivity": "secret",
233
240
  "default": null
234
241
  },
242
+ {
243
+ "name": "PLANU_TEST_HOME",
244
+ "type": "string",
245
+ "scope": "test",
246
+ "sensitivity": "public",
247
+ "default": null
248
+ },
235
249
  {
236
250
  "name": "PLANU_TIMING",
237
251
  "type": "boolean",
@@ -52,7 +52,7 @@ function registerImplementingCascadeListener() {
52
52
  });
53
53
  }
54
54
  // SPEC-649: registerDoneCascadeListener (optimize_context on done) removed — cascade reduced to
55
- // exactly 2 actions: session.json update + releases/pending.json append.
55
+ // exactly 2 actions: session.json update + project.json releases append.
56
56
  /**
57
57
  * SPEC-628: Fire-and-forget auto_fix_health on cron:daily.
58
58
  * Moved from update_status(done) cascade to reduce latency on done transitions.
@@ -35,13 +35,17 @@ export function normalizePendingReleaseEntries(value) {
35
35
  throw new Error('Pending release ledger is not an array');
36
36
  }
37
37
  const deduped = new Map();
38
+ const preserved = [];
38
39
  for (const entry of value) {
39
40
  if (!isValidPendingReleaseEntry(entry) || !hasParsableCompletionDate(entry)) {
41
+ // Unknown/invalid shape: never silently drop ledger data — pass it
42
+ // through verbatim so a human can inspect and fix it later.
43
+ preserved.push(entry);
40
44
  continue;
41
45
  }
42
46
  deduped.set(entry.specId, entry);
43
47
  }
44
- return [...deduped.values()];
48
+ return [...preserved, ...deduped.values()];
45
49
  }
46
50
  export function reconcilePublishedPendingEntries(entries, release, isCommitReachable = requireIsCommitReachable) {
47
51
  if (!release) {
@@ -78,16 +82,23 @@ async function captureHeadCommit(projectPath) {
78
82
  return undefined;
79
83
  }
80
84
  }
85
+ function extractReleasesField(parsedProject) {
86
+ return parsedProject !== null &&
87
+ typeof parsedProject === 'object' &&
88
+ !Array.isArray(parsedProject)
89
+ ? parsedProject.releases
90
+ : undefined;
91
+ }
81
92
  async function rewritePendingLedger(input) {
82
93
  let pendingList = [];
83
94
  try {
84
- const raw = await readFile(input.pendingPath, 'utf-8');
85
- pendingList = normalizePendingReleaseEntries(JSON.parse(raw));
95
+ const raw = await readFile(input.projectJsonPath, 'utf-8');
96
+ pendingList = normalizePendingReleaseEntries(extractReleasesField(JSON.parse(raw)) ?? []);
86
97
  }
87
98
  catch (error) {
88
99
  if (error.code !== 'ENOENT') {
89
100
  reportClassifiedDegradation('PENDING_RELEASE_LEDGER_UNREADABLE', error);
90
- return;
101
+ throw error instanceof Error ? error : new Error(String(error));
91
102
  }
92
103
  }
93
104
  pendingList = reconcilePublishedPendingEntries(pendingList, input.releaseInfo, input.isCommitReachable).filter((entry) => entry.specId !== input.specId);
@@ -97,7 +108,7 @@ async function rewritePendingLedger(input) {
97
108
  completedAt: new Date().toISOString().substring(0, 10),
98
109
  ...(input.implementationCommit ? { implementationCommit: input.implementationCommit } : {}),
99
110
  });
100
- await input.writePendingReleaseLedger(input.pendingPath, pendingList);
111
+ await input.writePendingReleaseLedger(input.projectJsonPath, pendingList);
101
112
  }
102
113
  async function actuallyAppendReleasesPending(ctx, opts) {
103
114
  const { projectPath, projectId, specId } = ctx;
@@ -116,19 +127,19 @@ async function actuallyAppendReleasesPending(ctx, opts) {
116
127
  return;
117
128
  }
118
129
  const { isCommitReachable, resolveLatestReleaseTag, withPendingReleaseLock, writePendingReleaseLedger, } = helpers;
119
- const releasesDir = join(projectPath, 'planu', 'releases');
120
- const pendingPath = join(releasesDir, 'pending.json');
130
+ const planuDir = join(projectPath, 'planu');
131
+ const projectJsonPath = join(planuDir, 'project.json');
121
132
  await new Promise((resolve, reject) => {
122
133
  opts.signal.addEventListener('abort', () => {
123
134
  reject(new Error('CoreActionTimeoutError: append-releases-pending timed out'));
124
135
  });
125
136
  (async () => {
126
- await mkdir(releasesDir, { recursive: true });
137
+ await mkdir(planuDir, { recursive: true });
127
138
  const spec = await specStore.getSpec(projectId, specId);
128
139
  const releaseInfo = await resolveLatestReleaseTag(projectPath);
129
140
  const implementationCommit = await captureHeadCommit(projectPath);
130
- await withPendingReleaseLock(pendingPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
131
- pendingPath,
141
+ await withPendingReleaseLock(projectJsonPath, { timeoutMs: 1_500, signal: opts.signal }, () => rewritePendingLedger({
142
+ projectJsonPath,
132
143
  specId,
133
144
  title: spec?.title ?? specId,
134
145
  implementationCommit,
@@ -150,8 +161,7 @@ async function actuallyAppendReleasesPending(ctx, opts) {
150
161
  resolve();
151
162
  })
152
163
  .catch((err) => {
153
- void err; // best-effort surface as result, not throw
154
- resolve();
164
+ reject(err instanceof Error ? err : new Error(String(err)));
155
165
  });
156
166
  });
157
167
  }
@@ -1,4 +1,4 @@
1
- import type { ConflictReport, Contradiction, SpecAnalysisInput } from '../types/index.js';
1
+ import type { ConflictReport, Contradiction, CrossSpecPremiseFinding, SpecAnalysisInput } from '../types/index.js';
2
2
  /**
3
3
  * Detect semantic contradictions between a list of specs.
4
4
  * Each spec is compared against every other spec for all known patterns.
@@ -12,4 +12,5 @@ export declare function buildConflictReport(specs: SpecAnalysisInput[]): Conflic
12
12
  * Format a ConflictReport as a markdown string.
13
13
  */
14
14
  export declare function formatConflictReport(report: ConflictReport): string;
15
+ export declare function detectCrossSpecPremiseContradictions(targetSpecId: string, targetRawContent: string, projectPath: string | undefined): Promise<CrossSpecPremiseFinding[]>;
15
16
  //# sourceMappingURL=contradiction-detector.d.ts.map
@@ -1,3 +1,8 @@
1
+ // engine/contradiction-detector.ts — Semantic contradiction detection (SPEC-038)
2
+ import { readFile, readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { findMarkdownSectionRange } from './spec-format/markdown-sections.js';
5
+ import { getUniqueTopLevelFrontmatterString, stripFrontmatter } from './frontmatter-parser.js';
1
6
  // ---------------------------------------------------------------------------
2
7
  // Contradiction patterns
3
8
  // ---------------------------------------------------------------------------
@@ -155,4 +160,214 @@ export function formatConflictReport(report) {
155
160
  }
156
161
  return lines.join('\n');
157
162
  }
163
+ const DIRECTIONAL_PHRASE_PAIRS = [
164
+ { phraseA: 'out of', phraseB: 'into' },
165
+ { phraseA: 're-roots at', phraseB: 'relocates to' },
166
+ { phraseA: 'removes', phraseB: 'adds' },
167
+ { phraseA: 'enables', phraseB: 'disables' },
168
+ ];
169
+ const SIGNIFICANT_WORD_STOPWORDS = new Set([
170
+ 'this',
171
+ 'that',
172
+ 'with',
173
+ 'from',
174
+ 'into',
175
+ 'spec',
176
+ 'specs',
177
+ 'after',
178
+ 'before',
179
+ 'while',
180
+ 'their',
181
+ 'them',
182
+ 'about',
183
+ 'have',
184
+ 'will',
185
+ 'when',
186
+ 'then',
187
+ 'also',
188
+ 'were',
189
+ 'been',
190
+ 'these',
191
+ 'those',
192
+ ]);
193
+ function splitBodyIntoSentenceFragments(body) {
194
+ const sentences = [];
195
+ for (const line of body.split('\n')) {
196
+ const trimmedLine = line.trim();
197
+ if (trimmedLine.length === 0) {
198
+ continue;
199
+ }
200
+ for (const clause of trimmedLine.split(/(?<=[.!?])\s+/)) {
201
+ const sentence = clause.trim();
202
+ if (sentence.length > 0) {
203
+ sentences.push(sentence);
204
+ }
205
+ }
206
+ }
207
+ return sentences;
208
+ }
209
+ /** Extracts distinct "SPEC-NNNN" references from body text, excluding the spec's own ID. */
210
+ function extractReferencedSpecIds(body, excludeSpecId) {
211
+ const ids = new Set();
212
+ for (const match of body.matchAll(/\bSPEC-(\d+)\b/gi)) {
213
+ const number = match[1];
214
+ if (!number) {
215
+ continue;
216
+ }
217
+ const id = `SPEC-${number}`;
218
+ if (id.toUpperCase() !== excludeSpecId.toUpperCase()) {
219
+ ids.add(id);
220
+ }
221
+ }
222
+ return [...ids];
223
+ }
224
+ /** Extracts backtick-quoted file paths declared under a spec's "## Files" section. */
225
+ function extractFilesSectionPaths(body) {
226
+ const section = findMarkdownSectionRange(body, 'Files');
227
+ const paths = new Set();
228
+ if (!section) {
229
+ return paths;
230
+ }
231
+ const sectionText = body.slice(section.contentStart, section.end);
232
+ for (const match of sectionText.matchAll(/`([\w./-]+\.[A-Za-z0-9]+)`/g)) {
233
+ const path = match[1];
234
+ if (path) {
235
+ paths.add(path);
236
+ }
237
+ }
238
+ return paths;
239
+ }
240
+ function extractSignificantWords(sentence) {
241
+ const words = sentence.toLowerCase().match(/[a-z][a-z0-9._/-]{3,}/g) ?? [];
242
+ return new Set(words.filter((word) => !SIGNIFICANT_WORD_STOPWORDS.has(word)));
243
+ }
244
+ function shareSignificantWord(sentenceA, sentenceB) {
245
+ const wordsA = extractSignificantWords(sentenceA);
246
+ for (const word of extractSignificantWords(sentenceB)) {
247
+ if (wordsA.has(word)) {
248
+ return true;
249
+ }
250
+ }
251
+ return false;
252
+ }
253
+ const DESTINATION_TOKEN_PATTERN = /^`([^`]+)`|^([\w][\w./-]*)/;
254
+ function normalizeDestinationToken(token) {
255
+ return token.replace(/`/g, '').replace(/\/+$/, '').toLowerCase();
256
+ }
257
+ function extractDestinationToken(sentence, phrase) {
258
+ const phraseIndex = sentence.toLowerCase().indexOf(phrase);
259
+ if (phraseIndex === -1) {
260
+ return null;
261
+ }
262
+ const after = sentence.slice(phraseIndex + phrase.length).trimStart();
263
+ const match = DESTINATION_TOKEN_PATTERN.exec(after);
264
+ const token = match?.[1] ?? match?.[2];
265
+ if (!token) {
266
+ return null;
267
+ }
268
+ const isBacktickQuoted = match?.[1] !== undefined;
269
+ if (!isBacktickQuoted && !/[./]/.test(token)) {
270
+ return null;
271
+ }
272
+ return normalizeDestinationToken(token);
273
+ }
274
+ function sameDestination(targetSentence, targetPhrase, siblingSentence, siblingPhrase) {
275
+ const targetDestination = extractDestinationToken(targetSentence, targetPhrase);
276
+ const siblingDestination = extractDestinationToken(siblingSentence, siblingPhrase);
277
+ return (targetDestination !== null &&
278
+ siblingDestination !== null &&
279
+ targetDestination === siblingDestination);
280
+ }
281
+ function findOppositeDirectionSentence(targetSentence, siblingSentences) {
282
+ const lowerTarget = targetSentence.toLowerCase();
283
+ for (const pair of DIRECTIONAL_PHRASE_PAIRS) {
284
+ const targetHasA = lowerTarget.includes(pair.phraseA);
285
+ const targetPhrase = targetHasA ? pair.phraseA : pair.phraseB;
286
+ const oppositePhrase = targetHasA ? pair.phraseB : pair.phraseA;
287
+ if (!targetHasA && !lowerTarget.includes(pair.phraseB)) {
288
+ continue;
289
+ }
290
+ for (const siblingSentence of siblingSentences) {
291
+ if (siblingSentence.toLowerCase().includes(oppositePhrase) &&
292
+ shareSignificantWord(targetSentence, siblingSentence) &&
293
+ !sameDestination(targetSentence, targetPhrase, siblingSentence, oppositePhrase)) {
294
+ return siblingSentence;
295
+ }
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+ async function readSiblingSpecMarkdown(projectPath, siblingSpecId) {
301
+ const specsDir = join(projectPath, 'planu', 'specs');
302
+ let entries;
303
+ try {
304
+ entries = await readdir(specsDir);
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ const dirPrefix = `${siblingSpecId}-`.toLowerCase();
310
+ const dirName = entries.find((entry) => entry.toLowerCase() === siblingSpecId.toLowerCase() ||
311
+ entry.toLowerCase().startsWith(dirPrefix));
312
+ if (!dirName) {
313
+ return null;
314
+ }
315
+ try {
316
+ return await readFile(join(specsDir, dirName, 'spec.md'), 'utf-8');
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ }
322
+ export async function detectCrossSpecPremiseContradictions(targetSpecId, targetRawContent, projectPath) {
323
+ if (!projectPath) {
324
+ return [];
325
+ }
326
+ const targetBody = stripFrontmatter(targetRawContent);
327
+ const referencedSpecIds = extractReferencedSpecIds(targetBody, targetSpecId);
328
+ if (referencedSpecIds.length === 0) {
329
+ return [];
330
+ }
331
+ const targetFilePaths = extractFilesSectionPaths(targetBody);
332
+ if (targetFilePaths.size === 0) {
333
+ return [];
334
+ }
335
+ const targetSentences = splitBodyIntoSentenceFragments(targetBody);
336
+ const findings = [];
337
+ for (const siblingSpecId of referencedSpecIds) {
338
+ const siblingRawContent = await readSiblingSpecMarkdown(projectPath, siblingSpecId);
339
+ if (!siblingRawContent) {
340
+ continue;
341
+ }
342
+ const siblingStatus = getUniqueTopLevelFrontmatterString(siblingRawContent, 'status');
343
+ if (siblingStatus !== 'done' && siblingStatus !== 'approved') {
344
+ continue;
345
+ }
346
+ const siblingBody = stripFrontmatter(siblingRawContent);
347
+ const siblingFilePaths = extractFilesSectionPaths(siblingBody);
348
+ const sharedFilePath = [...targetFilePaths].find((path) => siblingFilePaths.has(path));
349
+ if (!sharedFilePath) {
350
+ continue;
351
+ }
352
+ const claimSentences = targetSentences.filter((sentence) => sentence.toUpperCase().includes(siblingSpecId.toUpperCase()));
353
+ if (claimSentences.length === 0) {
354
+ continue;
355
+ }
356
+ const siblingSentences = splitBodyIntoSentenceFragments(siblingBody);
357
+ for (const targetSentence of claimSentences) {
358
+ const siblingSentence = findOppositeDirectionSentence(targetSentence, siblingSentences);
359
+ if (siblingSentence) {
360
+ findings.push({
361
+ targetSpecId,
362
+ siblingSpecId,
363
+ targetSentence,
364
+ siblingSentence,
365
+ sharedFilePath,
366
+ });
367
+ break;
368
+ }
369
+ }
370
+ }
371
+ return findings;
372
+ }
158
373
  //# sourceMappingURL=contradiction-detector.js.map
@@ -4,6 +4,7 @@ export { fileExists };
4
4
  declare const IGNORE_DIRS: string[];
5
5
  export declare function hasAnyFile(basePath: string, patterns: string[]): Promise<boolean>;
6
6
  export declare function collectDependencies(projectPath: string): Promise<Set<string>>;
7
+ export declare function collectRuntimeDependencies(projectPath: string): Promise<Set<string>>;
7
8
  export declare function loadStackSignatures(): Promise<StackSignatures>;
8
9
  export declare function loadArchPatterns(): Promise<ArchPatternsFile>;
9
10
  export declare function readJsonFile(filePath: string): Promise<Record<string, unknown> | null>;
@@ -53,6 +53,17 @@ export async function collectDependencies(projectPath) {
53
53
  await collectPhpDeps(projectPath, deps);
54
54
  return deps;
55
55
  }
56
+ export async function collectRuntimeDependencies(projectPath) {
57
+ const deps = new Set();
58
+ await collectNodeRuntimeDeps(projectPath, deps);
59
+ await collectPythonRequirements(projectPath, deps);
60
+ await collectPyprojectDeps(projectPath, deps);
61
+ await collectGoDeps(projectPath, deps);
62
+ await collectRubyDeps(projectPath, deps);
63
+ await collectRustDeps(projectPath, deps);
64
+ await collectPhpRuntimeDeps(projectPath, deps);
65
+ return deps;
66
+ }
56
67
  async function collectNodeDeps(projectPath, deps) {
57
68
  try {
58
69
  const pkgJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
@@ -67,6 +78,17 @@ async function collectNodeDeps(projectPath, deps) {
67
78
  // No package.json
68
79
  }
69
80
  }
81
+ async function collectNodeRuntimeDeps(projectPath, deps) {
82
+ try {
83
+ const pkgJson = JSON.parse(await readFile(join(projectPath, 'package.json'), 'utf-8'));
84
+ for (const dep of Object.keys(pkgJson.dependencies ?? {})) {
85
+ deps.add(dep);
86
+ }
87
+ }
88
+ catch {
89
+ // No package.json
90
+ }
91
+ }
70
92
  async function collectPythonRequirements(projectPath, deps) {
71
93
  try {
72
94
  const content = await readFile(join(projectPath, 'requirements.txt'), 'utf-8');
@@ -181,6 +203,17 @@ async function collectPhpDeps(projectPath, deps) {
181
203
  // No composer.json
182
204
  }
183
205
  }
206
+ async function collectPhpRuntimeDeps(projectPath, deps) {
207
+ try {
208
+ const composerJson = JSON.parse(await readFile(join(projectPath, 'composer.json'), 'utf-8'));
209
+ for (const dep of Object.keys(composerJson.require ?? {})) {
210
+ deps.add(dep);
211
+ }
212
+ }
213
+ catch {
214
+ // No composer.json
215
+ }
216
+ }
184
217
  export async function loadStackSignatures() {
185
218
  try {
186
219
  const configPath = new URL('../config/stack-signatures.json', import.meta.url);
@@ -16,7 +16,7 @@ export { computeVelocity } from './data-aggregators/velocity-aggregator.js';
16
16
  export { computeBurndown } from './data-aggregators/burndown-aggregator.js';
17
17
  export { detectBottlenecks } from './data-aggregators/bottleneck-detector.js';
18
18
  export { computeAccuracy } from './data-aggregators/accuracy-aggregator.js';
19
- export { scheduleRegeneration, regeneratePages } from './portal-regenerator.js';
19
+ export { scheduleRegeneration, regeneratePages, portalPageOutputPath, } from './portal-regenerator.js';
20
20
  export { notifyStoreChange } from './regen-hook.js';
21
21
  export { getAffectedPages } from './dirty-flag-map.js';
22
22
  //# sourceMappingURL=index.d.ts.map
@@ -19,7 +19,7 @@ export { computeBurndown } from './data-aggregators/burndown-aggregator.js';
19
19
  export { detectBottlenecks } from './data-aggregators/bottleneck-detector.js';
20
20
  export { computeAccuracy } from './data-aggregators/accuracy-aggregator.js';
21
21
  // SPEC-146 — Auto-regeneration on store changes
22
- export { scheduleRegeneration, regeneratePages } from './portal-regenerator.js';
22
+ export { scheduleRegeneration, regeneratePages, portalPageOutputPath, } from './portal-regenerator.js';
23
23
  export { notifyStoreChange } from './regen-hook.js';
24
24
  export { getAffectedPages } from './dirty-flag-map.js';
25
25
  //# sourceMappingURL=index.js.map