@planu/cli 4.1.1 → 4.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/config/license-plans.json +65 -361
  3. package/dist/tools/git/hook-ops.js +23 -9
  4. package/dist/tools/tool-registry/group-infra.js +22 -0
  5. package/package.json +7 -7
  6. package/dist/engine/escalator/index.d.ts +0 -5
  7. package/dist/engine/escalator/index.js +0 -5
  8. package/dist/engine/freeze/retro-audit.d.ts +0 -6
  9. package/dist/engine/freeze/retro-audit.js +0 -24
  10. package/dist/engine/heal/backup.d.ts +0 -9
  11. package/dist/engine/heal/backup.js +0 -21
  12. package/dist/engine/idioma-validator/index.d.ts +0 -17
  13. package/dist/engine/idioma-validator/index.js +0 -89
  14. package/dist/engine/saga/index.d.ts +0 -4
  15. package/dist/engine/saga/index.js +0 -4
  16. package/dist/engine/spec-state-machine/index.d.ts +0 -3
  17. package/dist/engine/spec-state-machine/index.js +0 -2
  18. package/dist/engine/spec-summary-html/dashboard-renderer.d.ts +0 -6
  19. package/dist/engine/spec-summary-html/dashboard-renderer.js +0 -333
  20. package/dist/engine/triagier/index.d.ts +0 -5
  21. package/dist/engine/triagier/index.js +0 -5
  22. package/dist/engine/universal-rules/index.d.ts +0 -5
  23. package/dist/engine/universal-rules/index.js +0 -6
  24. package/dist/testing/cassette/index.d.ts +0 -23
  25. package/dist/testing/cassette/index.js +0 -26
  26. package/dist/tools/domain-bundle-handler.d.ts +0 -37
  27. package/dist/tools/domain-bundle-handler.js +0 -71
  28. package/dist/tools/figma/rules-file.d.ts +0 -5
  29. package/dist/tools/figma/rules-file.js +0 -45
  30. package/dist/tools/heal-planu-root.d.ts +0 -8
  31. package/dist/tools/heal-planu-root.js +0 -144
  32. package/dist/tools/opencode-host-adapter.d.ts +0 -3
  33. package/dist/tools/opencode-host-adapter.js +0 -33
  34. package/dist/tools/plan-team-distribution.d.ts +0 -3
  35. package/dist/tools/plan-team-distribution.js +0 -71
  36. package/dist/tools/reconcile-status-json.d.ts +0 -4
  37. package/dist/tools/reconcile-status-json.js +0 -209
  38. package/dist/tools/register-all-tools.d.ts +0 -8
  39. package/dist/tools/register-all-tools.js +0 -239
  40. package/dist/tools/tool-registry/group-analysis-monitoring.d.ts +0 -3
  41. package/dist/tools/tool-registry/group-analysis-monitoring.js +0 -942
  42. package/dist/tools/tool-registry/group-integrations.d.ts +0 -3
  43. package/dist/tools/tool-registry/group-integrations.js +0 -1046
  44. package/dist/tools/tool-registry/group-misc.d.ts +0 -3
  45. package/dist/tools/tool-registry/group-misc.js +0 -1367
  46. package/dist/tools/tool-registry/group-platform.d.ts +0 -3
  47. package/dist/tools/tool-registry/group-platform.js +0 -1681
  48. package/dist/tools/tool-registry/group-session-knowledge.d.ts +0 -3
  49. package/dist/tools/tool-registry/group-session-knowledge.js +0 -1416
  50. package/dist/tools/tool-registry/group-spec-ops.d.ts +0 -3
  51. package/dist/tools/tool-registry/group-spec-ops.js +0 -917
  52. package/dist/tools/workspace-overview.d.ts +0 -4
  53. package/dist/tools/workspace-overview.js +0 -316
  54. package/dist/transports/middleware/index.d.ts +0 -9
  55. package/dist/transports/middleware/index.js +0 -7
  56. package/dist/transports/middleware/with-sandbox.d.ts +0 -21
  57. package/dist/transports/middleware/with-sandbox.js +0 -68
  58. package/dist/types/heal.d.ts +0 -18
  59. package/dist/types/heal.js +0 -3
@@ -1,26 +0,0 @@
1
- // testing/cassette/index.ts — Public API for LLM cassette/mock pattern (SPEC-743)
2
- export { computeRequestSha } from './signature.js';
3
- export { loadCassetteFromDisk as loadCassette, saveCassetteToDisk, upsertCassetteEntry, getCassettePath, } from './store.js';
4
- export { createInterceptor, isRecordingMode, CassetteMissError } from './intercept.js';
5
- import { upsertCassetteEntry } from './store.js';
6
- /**
7
- * withCassette — Higher-order helper for test cases.
8
- * Runs a test function within a cassette scope.
9
- * Actual interception must be installed by the caller via createInterceptor().
10
- *
11
- * @example
12
- * it('generates intake JSON', () => withCassette('handoff-artifacts/intake', async () => {
13
- * const result = await generateIntakeJson(spec);
14
- * expect(result).toMatchSnapshot();
15
- * }));
16
- */
17
- export async function withCassette(_cassetteName, fn) {
18
- return fn();
19
- }
20
- /**
21
- * recordCassette — Write a new entry to a cassette file (record mode only).
22
- */
23
- export function recordCassette(name, entry) {
24
- upsertCassetteEntry(name, entry);
25
- }
26
- //# sourceMappingURL=index.js.map
@@ -1,37 +0,0 @@
1
- import { z } from 'zod';
2
- export declare const ApplyDomainBundleInputSchema: {
3
- bundle: z.ZodEnum<{
4
- "react-native": "react-native";
5
- "rest-api": "rest-api";
6
- "stripe-payments": "stripe-payments";
7
- "auth-supabase": "auth-supabase";
8
- "nextjs-fullstack": "nextjs-fullstack";
9
- }>;
10
- projectPath: z.ZodString;
11
- overwrite: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
12
- };
13
- export declare const ListDomainBundlesInputSchema: {
14
- projectPath: z.ZodOptional<z.ZodString>;
15
- };
16
- interface ApplyDomainBundleArgs {
17
- bundle: string;
18
- projectPath: string;
19
- overwrite?: boolean;
20
- }
21
- interface ListDomainBundlesArgs {
22
- projectPath?: string;
23
- }
24
- export declare function handleApplyDomainBundle(args: ApplyDomainBundleArgs): Promise<{
25
- content: {
26
- type: 'text';
27
- text: string;
28
- }[];
29
- }>;
30
- export declare function handleListDomainBundles(_args: ListDomainBundlesArgs): Promise<{
31
- content: {
32
- type: 'text';
33
- text: string;
34
- }[];
35
- }>;
36
- export {};
37
- //# sourceMappingURL=domain-bundle-handler.d.ts.map
@@ -1,71 +0,0 @@
1
- // Planu — domain-bundle-handler.ts
2
- // Handlers for apply_domain_bundle and list_domain_bundles tools.
3
- import { z } from 'zod';
4
- import { installBundle, listBundlesAsync } from '../engine/bundle-installer.js';
5
- import { compactJson, compactResult } from './output-formatter.js';
6
- // ---------------------------------------------------------------------------
7
- // Zod schemas
8
- // ---------------------------------------------------------------------------
9
- export const ApplyDomainBundleInputSchema = {
10
- bundle: z
11
- .enum(['stripe-payments', 'auth-supabase', 'rest-api', 'nextjs-fullstack', 'react-native'])
12
- .describe('Bundle ID to install. Valid values: stripe-payments, auth-supabase, rest-api, nextjs-fullstack, react-native'),
13
- projectPath: z
14
- .string()
15
- .describe('Absolute path to the project where the bundle will be installed'),
16
- overwrite: z
17
- .boolean()
18
- .optional()
19
- .default(false)
20
- .describe('If true, overwrite existing files. Default: false (skip existing files)'),
21
- };
22
- export const ListDomainBundlesInputSchema = {
23
- projectPath: z
24
- .string()
25
- .optional()
26
- .describe('Optional project path (unused, included for consistency)'),
27
- };
28
- function formatInstallResult(result) {
29
- const lines = [`Bundle "${result.bundleId}" installation complete.`];
30
- if (result.installed.length > 0) {
31
- lines.push(`\nInstalled (${result.installed.length}):`);
32
- for (const path of result.installed) {
33
- lines.push(` + ${path}`);
34
- }
35
- }
36
- if (result.skipped.length > 0) {
37
- lines.push(`\nSkipped — already exist (${result.skipped.length}):`);
38
- for (const path of result.skipped) {
39
- lines.push(` ~ ${path}`);
40
- }
41
- }
42
- if (result.errors.length > 0) {
43
- lines.push(`\nErrors (${result.errors.length}):`);
44
- for (const err of result.errors) {
45
- lines.push(` ! ${err}`);
46
- }
47
- }
48
- return lines.join('\n');
49
- }
50
- export async function handleApplyDomainBundle(args) {
51
- const result = await installBundle(args.bundle, args.projectPath, {
52
- overwrite: args.overwrite ?? false,
53
- });
54
- const summary = formatInstallResult(result);
55
- const humanSummary = result.errors.length > 0
56
- ? `Bundle "${result.bundleId}" installed with ${result.errors.length} error(s). Check errors for details.`
57
- : `Bundle "${result.bundleId}" installed successfully: ${result.installed.length} file(s) added, ${result.skipped.length} skipped.`;
58
- return compactResult(compactJson({ ...result, summary, humanSummary }));
59
- }
60
- export async function handleListDomainBundles(_args) {
61
- const bundles = await listBundlesAsync();
62
- const items = bundles.map((b) => ({
63
- id: b.id,
64
- name: b.name,
65
- description: b.description,
66
- tags: b.tags,
67
- }));
68
- const humanSummary = `${items.length} domain bundles available: ${items.map((b) => b.name).join(', ')}.`;
69
- return compactResult(compactJson({ bundles: items, humanSummary }));
70
- }
71
- //# sourceMappingURL=domain-bundle-handler.js.map
@@ -1,5 +0,0 @@
1
- import type { ToolResult } from '../../types/index.js';
2
- export declare function handleGenerateFigmaRulesFile(input: {
3
- projectPath: string;
4
- }): Promise<ToolResult>;
5
- //# sourceMappingURL=rules-file.d.ts.map
@@ -1,45 +0,0 @@
1
- // tools/figma/rules-file.ts — SPEC-488: generate_figma_rules_file handler
2
- import { writeFile, mkdir } from 'node:fs/promises';
3
- import { join } from 'node:path';
4
- import { getFigmaConfig, getFigmaCodeConnect, getFigmaTokens } from '../../storage/figma-store.js';
5
- import { knowledgeStore } from '../../storage/index.js';
6
- import { hashProjectPath } from '../../storage/base-store.js';
7
- import { buildFigmaRulesFileContent } from '../../engine/figma/implementation-rules.js';
8
- import { FIGMA_NOT_CONNECTED } from './shared.js';
9
- export async function handleGenerateFigmaRulesFile(input) {
10
- const config = await getFigmaConfig(input.projectPath);
11
- if (config === null) {
12
- return FIGMA_NOT_CONNECTED;
13
- }
14
- const projectId = hashProjectPath(input.projectPath);
15
- const knowledge = await knowledgeStore.getKnowledge(projectId);
16
- const [codeConnectEntries, tokens] = await Promise.all([
17
- getFigmaCodeConnect(input.projectPath),
18
- getFigmaTokens(input.projectPath),
19
- ]);
20
- const content = buildFigmaRulesFileContent({
21
- codeConnectEntries,
22
- tokens,
23
- stack: knowledge?.stack ?? [],
24
- framework: knowledge?.framework ?? null,
25
- });
26
- const rulesDir = join(input.projectPath, '.claude', 'rules');
27
- const rulesPath = join(rulesDir, 'figma-design-system.md');
28
- await mkdir(rulesDir, { recursive: true });
29
- await writeFile(rulesPath, content, 'utf-8');
30
- const summary = [
31
- `✓ Created .claude/rules/figma-design-system.md`,
32
- ` ${String(codeConnectEntries.length)} Code Connect components`,
33
- ` ${String(tokens.length)} design tokens`,
34
- ` Stack: ${knowledge?.framework ?? knowledge?.stack.slice(0, 3).join(', ') ?? 'unknown'}`,
35
- ].join('\n');
36
- return {
37
- content: [{ type: 'text', text: summary }],
38
- structuredContent: {
39
- rulesPath,
40
- codeConnectCount: codeConnectEntries.length,
41
- tokenCount: tokens.length,
42
- },
43
- };
44
- }
45
- //# sourceMappingURL=rules-file.js.map
@@ -1,8 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
- import type { HealRunResult } from '../types/heal.js';
3
- export declare function healPlanuRoot(opts: {
4
- projectRoot: string;
5
- dryRun?: boolean;
6
- }): Promise<HealRunResult>;
7
- export declare function registerHealPlanuRootTool(server: McpServer): void;
8
- //# sourceMappingURL=heal-planu-root.d.ts.map
@@ -1,144 +0,0 @@
1
- // tools/heal-planu-root.ts — SPEC-745
2
- // heal_planu_root tool with 3-tier policy: auto-fix / propose-only / never-touch
3
- import { z } from 'zod';
4
- import { readdirSync, existsSync } from 'node:fs';
5
- import { join } from 'node:path';
6
- import { safeTracked } from './safe-handler.js';
7
- import { readDoNotHealList } from '../engine/heal/markers.js';
8
- import { classifyFile } from '../engine/heal/policy.js';
9
- import { backupFile } from '../engine/heal/backup.js';
10
- import { atomicWriteFile } from '../engine/safety/atomic-write-file.js';
11
- import { writeHealProposal } from '../engine/heal/proposal-writer.js';
12
- // ---------------------------------------------------------------------------
13
- // Public API
14
- // ---------------------------------------------------------------------------
15
- export async function healPlanuRoot(opts) {
16
- const { projectRoot, dryRun = false } = opts;
17
- const planuDir = join(projectRoot, 'planu');
18
- const allowlist = readDoNotHealList(projectRoot);
19
- // Collect candidate files from planu/ root (non-recursive for safety)
20
- const candidateFiles = [];
21
- if (existsSync(planuDir)) {
22
- try {
23
- const entries = readdirSync(planuDir, { withFileTypes: true });
24
- for (const e of entries) {
25
- if (e.isFile() && (e.name.endsWith('.json') || e.name.endsWith('.jsonl'))) {
26
- candidateFiles.push(join(planuDir, e.name));
27
- }
28
- }
29
- }
30
- catch {
31
- // ignore
32
- }
33
- }
34
- const results = [];
35
- const skipped = [];
36
- const now = Date.now();
37
- for (const filePath of candidateFiles) {
38
- const policy = classifyFile(filePath, allowlist);
39
- if (policy.tier === 3) {
40
- if (policy.reason === 'clean') {
41
- continue; // not a candidate
42
- }
43
- skipped.push({ path: filePath, reason: policy.reason });
44
- results.push({ path: filePath, tier: 3, action: 'skipped', reason: policy.reason });
45
- continue;
46
- }
47
- if (policy.tier === 1 && policy.repairedContent !== undefined) {
48
- if (!dryRun) {
49
- // Backup BEFORE write
50
- const backupPath = await backupFile(filePath, now);
51
- await atomicWriteFile(filePath, policy.repairedContent);
52
- results.push({
53
- path: filePath,
54
- tier: 1,
55
- action: 'auto-fixed',
56
- backup: backupPath,
57
- reason: policy.reason,
58
- });
59
- }
60
- else {
61
- results.push({
62
- path: filePath,
63
- tier: 1,
64
- action: 'auto-fixed',
65
- reason: `[dryRun] ${policy.reason}`,
66
- });
67
- }
68
- continue;
69
- }
70
- if (policy.tier === 2 && policy.proposedContent !== undefined) {
71
- if (!dryRun) {
72
- const proposalPath = await writeHealProposal(filePath, {
73
- originalPath: filePath,
74
- proposedContent: policy.proposedContent,
75
- violations: policy.violations ?? [],
76
- generatedAt: new Date().toISOString(),
77
- }, now);
78
- results.push({
79
- path: filePath,
80
- tier: 2,
81
- action: 'proposal-emitted',
82
- proposalPath,
83
- reason: policy.reason,
84
- });
85
- }
86
- else {
87
- results.push({
88
- path: filePath,
89
- tier: 2,
90
- action: 'proposal-emitted',
91
- reason: `[dryRun] ${policy.reason}`,
92
- });
93
- }
94
- }
95
- }
96
- return { scanned: candidateFiles.length, results, skipped };
97
- }
98
- // ---------------------------------------------------------------------------
99
- // MCP registration
100
- // ---------------------------------------------------------------------------
101
- export function registerHealPlanuRootTool(server) {
102
- server.registerTool('heal_planu_root', {
103
- description: 'Run a 3-tier heal pass over planu/ config files. ' +
104
- 'Tier-1 (auto-fix): invalid JSON syntax is repaired atomically with a .bak backup. ' +
105
- 'Tier-2 (propose-only): out-of-range values generate a .heal-proposal.<ts>.json for human review. ' +
106
- 'Tier-3 (never-touch): files with `// custom` header or in .planu-do-not-heal are skipped. ' +
107
- 'Use dryRun=true to preview without writing.',
108
- inputSchema: {
109
- projectPath: z.string().min(1).max(4096).describe('Absolute path to project root.'),
110
- dryRun: z
111
- .boolean()
112
- .optional()
113
- .describe('Preview mode — reports what would be done without writing. Default: false.'),
114
- },
115
- }, safeTracked('heal_planu_root', async (args) => {
116
- const { projectPath, dryRun } = args;
117
- const result = await healPlanuRoot({ projectRoot: projectPath, dryRun });
118
- const autoFixed = result.results.filter((r) => r.action === 'auto-fixed').length;
119
- const proposals = result.results.filter((r) => r.action === 'proposal-emitted').length;
120
- const skippedCount = result.results.filter((r) => r.action === 'skipped').length;
121
- return {
122
- content: [
123
- {
124
- type: 'text',
125
- text: [
126
- `# Heal Planu Root${dryRun ? ' (dry run)' : ''}`,
127
- ``,
128
- `**Scanned**: ${result.scanned} files`,
129
- `**Auto-fixed (tier-1)**: ${autoFixed}`,
130
- `**Proposals emitted (tier-2)**: ${proposals}`,
131
- `**Skipped (tier-3)**: ${skippedCount}`,
132
- ``,
133
- result.results.length > 0
134
- ? result.results
135
- .map((r) => `- [tier-${r.tier}] ${r.action}: \`${r.path}\`${r.backup ? ` → backup: \`${r.backup}\`` : ''}${r.proposalPath ? ` → proposal: \`${r.proposalPath}\`` : ''}${r.reason ? ` (${r.reason})` : ''}`)
136
- .join('\n')
137
- : '_No issues found._',
138
- ].join('\n'),
139
- },
140
- ],
141
- };
142
- }));
143
- }
144
- //# sourceMappingURL=heal-planu-root.js.map
@@ -1,3 +0,0 @@
1
- import type { OpenCodeAdapterInput, ToolResult } from '../types/index.js';
2
- export declare function handleOpenCodeHostAdapter(args: OpenCodeAdapterInput): Promise<ToolResult>;
3
- //# sourceMappingURL=opencode-host-adapter.d.ts.map
@@ -1,33 +0,0 @@
1
- // src/tools/opencode-host-adapter.ts — SPEC-966 tool handler
2
- import { runOpenCodeAdapter } from '../engine/opencode/adapter.js';
3
- export async function handleOpenCodeHostAdapter(args) {
4
- const result = await runOpenCodeAdapter(args);
5
- const lines = [];
6
- lines.push(`# OpenCode Host Adapter — ${result.detected ? '✅ Detected' : '❌ Not Detected'}`);
7
- lines.push('');
8
- lines.push(`**Markers found:** ${result.markers.join(', ') || 'none'}`);
9
- if (result.configPath) {
10
- lines.push(`**Config path:** ${result.configPath}`);
11
- }
12
- lines.push('');
13
- if (result.scaffoldedFiles) {
14
- lines.push('## Scaffolded Files');
15
- for (const file of result.scaffoldedFiles) {
16
- lines.push(`- ${file}`);
17
- }
18
- lines.push('');
19
- }
20
- if (result.coachRules) {
21
- lines.push('## Coach Rules');
22
- for (const rule of result.coachRules) {
23
- lines.push(`- ${rule}`);
24
- }
25
- lines.push('');
26
- }
27
- return {
28
- content: [{ type: 'text', text: lines.join('\n') }],
29
- structuredContent: result,
30
- isError: false,
31
- };
32
- }
33
- //# sourceMappingURL=opencode-host-adapter.js.map
@@ -1,3 +0,0 @@
1
- import type { PlanTeamDistributionInput, ToolResult } from '../types/index.js';
2
- export declare function handlePlanTeamDistribution(params: PlanTeamDistributionInput): Promise<ToolResult>;
3
- //# sourceMappingURL=plan-team-distribution.d.ts.map
@@ -1,71 +0,0 @@
1
- // tools/plan-team-distribution.ts — Expose planTeamDistribution as MCP tool (SPEC-091)
2
- // Loads specs, extracts file ownership from each spec.md, and delegates to the engine.
3
- import { readFile } from 'node:fs/promises';
4
- import { specStore } from '../storage/index.js';
5
- import { planTeamDistribution } from '../engine/team-planner/index.js';
6
- const FILE_PATTERN = /\b([\w./-]+\.(?:ts|tsx|js|jsx|py|go|rs|java|rb|cs|json|yaml|yml|vue|svelte))\b/g;
7
- function extractFilesFromContent(content) {
8
- const found = new Set();
9
- for (const line of content.split('\n')) {
10
- if (line.trimStart().startsWith('#')) {
11
- continue;
12
- }
13
- FILE_PATTERN.lastIndex = 0;
14
- let match;
15
- while ((match = FILE_PATTERN.exec(line)) !== null) {
16
- const raw = match[1];
17
- if (raw && raw.length >= 5) {
18
- found.add(raw);
19
- }
20
- }
21
- }
22
- return [...found];
23
- }
24
- async function readSpecFiles(specPath) {
25
- try {
26
- const content = await readFile(specPath, 'utf-8');
27
- return extractFilesFromContent(content);
28
- }
29
- catch {
30
- return [];
31
- }
32
- }
33
- export async function handlePlanTeamDistribution(params) {
34
- const { projectId, specIds, template } = params;
35
- if (specIds.length === 0) {
36
- return {
37
- content: [
38
- {
39
- type: 'text',
40
- text: 'specIds must contain at least one spec ID. Provide the specs to distribute across the team.',
41
- },
42
- ],
43
- isError: true,
44
- };
45
- }
46
- const specs = [];
47
- const fileOwnership = {};
48
- for (const specId of specIds) {
49
- const spec = await specStore.getSpec(projectId, specId);
50
- if (!spec) {
51
- return {
52
- content: [
53
- {
54
- type: 'text',
55
- text: `Spec '${specId}' not found in project '${projectId}'. Check the spec ID.`,
56
- },
57
- ],
58
- isError: true,
59
- };
60
- }
61
- specs.push({ id: spec.id, title: spec.title });
62
- fileOwnership[spec.id] = await readSpecFiles(spec.specPath);
63
- }
64
- const templateRef = template !== undefined && template.length > 0 ? { id: template, name: template } : undefined;
65
- const plan = planTeamDistribution(specs, fileOwnership, templateRef);
66
- return {
67
- content: [{ type: 'text', text: JSON.stringify(plan, null, 2) }],
68
- structuredContent: { plan },
69
- };
70
- }
71
- //# sourceMappingURL=plan-team-distribution.js.map
@@ -1,4 +0,0 @@
1
- import type { ToolResult } from '../types/index.js';
2
- import type { ReconcileStatusJsonInput } from '../types/workspace-overview.js';
3
- export declare function handleReconcileStatusJson(params: ReconcileStatusJsonInput): Promise<ToolResult>;
4
- //# sourceMappingURL=reconcile-status-json.d.ts.map
@@ -1,209 +0,0 @@
1
- // tools/reconcile-status-json.ts — SPEC-753: reconcile_status_json tool
2
- //
3
- // Reconciles status.json spec statuses with actual spec.md frontmatter values.
4
- // Source-of-truth rule: 'frontmatter-wins' | 'status-wins' | 'newest-wins'.
5
- import { join } from 'node:path';
6
- import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
7
- import { resolveProjectPath } from '../storage/path-resolver.js';
8
- import { hashProjectPath } from '../storage/base-store.js';
9
- import { withStatusLock } from '../storage/status-store/file-lock.js';
10
- // ---------------------------------------------------------------------------
11
- // Frontmatter parser helpers (local, not exported — types in types/workspace-overview.ts)
12
- // ---------------------------------------------------------------------------
13
- function parseFrontmatter(content) {
14
- const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content);
15
- if (!match?.[1]) {
16
- return null;
17
- }
18
- const block = match[1];
19
- const result = {};
20
- for (const line of block.split('\n')) {
21
- const colonIdx = line.indexOf(':');
22
- if (colonIdx === -1) {
23
- continue;
24
- }
25
- const key = line.slice(0, colonIdx).trim();
26
- const val = line
27
- .slice(colonIdx + 1)
28
- .trim()
29
- .replace(/^["']|["']$/g, '');
30
- if (key) {
31
- result[key] = val;
32
- }
33
- }
34
- if (!result.id || !result.status) {
35
- return null;
36
- }
37
- return {
38
- id: result.id,
39
- status: result.status,
40
- updated: result.updated,
41
- };
42
- }
43
- async function readStatusJson(statusPath) {
44
- try {
45
- const raw = await readFile(statusPath, 'utf-8');
46
- const parsed = JSON.parse(raw);
47
- if (typeof parsed === 'object' && parsed !== null) {
48
- return parsed;
49
- }
50
- return null;
51
- }
52
- catch {
53
- return null;
54
- }
55
- }
56
- /**
57
- * Build a map of specId → status from recentChanges in status.json.
58
- */
59
- function buildStatusJsonSpecMap(content) {
60
- const map = new Map();
61
- if (Array.isArray(content.recentChanges)) {
62
- for (const change of content.recentChanges) {
63
- if (change.specId && change.to) {
64
- map.set(change.specId, change.to);
65
- }
66
- }
67
- }
68
- return map;
69
- }
70
- function resolveConflict(_specId, statusJsonStatus, frontmatterStatus, _statusJsonUpdatedAt, source) {
71
- const sjStatus = statusJsonStatus ?? 'unknown';
72
- if (sjStatus === frontmatterStatus) {
73
- return { resolved: frontmatterStatus, changed: false };
74
- }
75
- let resolved;
76
- if (source === 'frontmatter-wins') {
77
- resolved = frontmatterStatus;
78
- }
79
- else if (source === 'status-wins') {
80
- resolved = sjStatus !== 'unknown' ? sjStatus : frontmatterStatus;
81
- }
82
- else {
83
- // newest-wins: frontmatter is considered most recent edit
84
- resolved = frontmatterStatus;
85
- }
86
- return { resolved, changed: true };
87
- }
88
- // ---------------------------------------------------------------------------
89
- // Main handler
90
- // ---------------------------------------------------------------------------
91
- export async function handleReconcileStatusJson(params) {
92
- const { source = 'frontmatter-wins', dryRun = true } = params;
93
- const { projectPath } = await resolveProjectPath(params.projectPath);
94
- const projectId = hashProjectPath(projectPath);
95
- const specsDir = join(projectPath, 'planu', 'specs');
96
- const statusPath = join(projectPath, 'planu', 'status.json');
97
- const statusContent = await readStatusJson(statusPath);
98
- const statusJsonMap = statusContent
99
- ? buildStatusJsonSpecMap(statusContent)
100
- : new Map();
101
- const statusJsonUpdatedAt = statusContent?.updatedAt;
102
- const conflicts = [];
103
- let specDirs = [];
104
- try {
105
- specDirs = await readdir(specsDir);
106
- }
107
- catch {
108
- // No specs dir
109
- }
110
- for (const dir of specDirs) {
111
- const specPath = join(specsDir, dir, 'spec.md');
112
- let content;
113
- try {
114
- content = await readFile(specPath, 'utf-8');
115
- }
116
- catch {
117
- continue;
118
- }
119
- const fm = parseFrontmatter(content);
120
- if (!fm) {
121
- continue;
122
- }
123
- const sjStatus = statusJsonMap.get(fm.id);
124
- const { resolved, changed } = resolveConflict(fm.id, sjStatus, fm.status, statusJsonUpdatedAt, source);
125
- if (changed) {
126
- conflicts.push({
127
- specId: fm.id,
128
- statusJson: sjStatus ?? 'not-in-recentChanges',
129
- frontmatter: fm.status,
130
- resolved,
131
- });
132
- }
133
- }
134
- if (!dryRun && conflicts.length > 0 && statusContent) {
135
- await withStatusLock(statusPath, async () => {
136
- const { specStore } = await import('../storage/index.js');
137
- const allSpecs = await specStore.listSpecs(projectId);
138
- const correctedMap = new Map();
139
- for (const c of conflicts) {
140
- correctedMap.set(c.specId, c.resolved);
141
- }
142
- const newByStatus = {};
143
- for (const spec of allSpecs) {
144
- const correctedStatus = correctedMap.get(spec.id) ?? spec.status;
145
- newByStatus[correctedStatus] = (newByStatus[correctedStatus] ?? 0) + 1;
146
- }
147
- const updated = {
148
- ...statusContent,
149
- byStatus: newByStatus,
150
- totalSpecs: allSpecs.length,
151
- updatedAt: new Date().toISOString(),
152
- };
153
- await mkdir(join(projectPath, 'planu'), { recursive: true });
154
- await writeFile(statusPath, JSON.stringify(updated, null, 2), 'utf-8');
155
- });
156
- // Log corrections to transition-log.jsonl
157
- try {
158
- const { appendTransitionEvent } = await import('../storage/transition-log.js');
159
- for (const conflict of conflicts) {
160
- await appendTransitionEvent({
161
- projectId,
162
- specId: conflict.specId,
163
- eventType: 'status_reconciled',
164
- from: conflict.statusJson,
165
- to: conflict.resolved,
166
- actor: 'reconcile_status_json',
167
- reason: `source: ${source}`,
168
- sessionId: 'reconcile',
169
- });
170
- }
171
- }
172
- catch {
173
- // Non-fatal
174
- }
175
- }
176
- const result = {
177
- corrected: conflicts.length,
178
- dryRun,
179
- source,
180
- conflicts,
181
- message: dryRun
182
- ? `Dry run: found ${String(conflicts.length)} discrepancies. Set dryRun=false to apply.`
183
- : `Applied ${String(conflicts.length)} corrections with source '${source}'.`,
184
- };
185
- const lines = [
186
- `# reconcile_status_json${dryRun ? ' (dry run)' : ''}`,
187
- ``,
188
- `**Source:** ${source}`,
189
- `**Discrepancies found:** ${String(conflicts.length)}`,
190
- `**Applied:** ${dryRun ? 'No (dryRun=true)' : 'Yes'}`,
191
- ``,
192
- ];
193
- if (conflicts.length > 0) {
194
- lines.push(`## Conflicts`);
195
- lines.push(`| Spec | status.json | frontmatter | resolved |`);
196
- lines.push(`|------|------------|-------------|----------|`);
197
- for (const c of conflicts) {
198
- lines.push(`| ${c.specId} | ${c.statusJson} | ${c.frontmatter} | **${c.resolved}** |`);
199
- }
200
- }
201
- else {
202
- lines.push(`No discrepancies found — status.json is in sync with frontmatters.`);
203
- }
204
- return {
205
- content: [{ type: 'text', text: lines.join('\n') }],
206
- structuredContent: result,
207
- };
208
- }
209
- //# sourceMappingURL=reconcile-status-json.js.map