@planu/cli 3.9.13 → 4.0.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.
- package/CHANGELOG.md +5 -0
- package/dist/cli/commands/spec.js +20 -1
- package/dist/cli/commands/status.js +18 -1
- package/dist/config/license-plans.json +1 -0
- package/dist/engine/ai-integration/codex/hooks-generator.js +1 -0
- package/dist/engine/ai-integration/gemini/settings-generator.js +4 -1
- package/dist/engine/ai-integration/kiro/hooks-generator.js +2 -1
- package/dist/engine/autopilot/action-registry.js +5 -14
- package/dist/engine/autopilot/state-updater.js +13 -10
- package/dist/engine/cascade-hooks/hooks/git-auto-stage.hook.js +3 -0
- package/dist/engine/cascade-hooks/hooks/html-regen.hook.js +1 -1
- package/dist/engine/cascade-hooks/hooks/status-json.hook.js +1 -1
- package/dist/engine/cascade-hooks/state-drift-detector.d.ts +1 -1
- package/dist/engine/cascade-hooks/state-drift-detector.js +15 -12
- package/dist/engine/git/planu-autocommit.d.ts +1 -0
- package/dist/engine/git/planu-autocommit.js +6 -0
- package/dist/engine/git-hook-injector.js +3 -3
- package/dist/engine/handoff-artifacts/io.js +3 -2
- package/dist/engine/handoff-packager.js +2 -1
- package/dist/engine/hooks/full-spectrum-generator.d.ts +2 -1
- package/dist/engine/hooks/full-spectrum-generator.js +5 -3
- package/dist/engine/release/postmortem-generator.d.ts +1 -1
- package/dist/engine/release/postmortem-generator.js +3 -2
- package/dist/engine/safety/cross-process-lock.js +2 -2
- package/dist/engine/session/checkpoint-writer.js +0 -1
- package/dist/engine/session-context-generator.js +4 -1
- package/dist/engine/spec-audit/index.js +2 -2
- package/dist/engine/spec-audit/report-writer.d.ts +1 -1
- package/dist/engine/spec-audit/report-writer.js +5 -4
- package/dist/engine/spec-migrator/index.d.ts +1 -0
- package/dist/engine/spec-migrator/index.js +1 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.d.ts +9 -0
- package/dist/engine/spec-migrator/planu-canonical-policy.js +62 -0
- package/dist/engine/spec-migrator/planu-root-cleaner.js +18 -94
- package/dist/engine/spec-migrator/strict-planu-cleanup.d.ts +6 -0
- package/dist/engine/spec-migrator/strict-planu-cleanup.js +199 -0
- package/dist/engine/spec-summary-html.d.ts +5 -5
- package/dist/engine/spec-summary-html.js +7 -32
- package/dist/storage/gaps-log.js +4 -4
- package/dist/storage/transition-log.js +3 -2
- package/dist/tools/audit-specs-drift.js +3 -3
- package/dist/tools/create-spec/post-creation.d.ts +2 -1
- package/dist/tools/create-spec/post-creation.js +9 -11
- package/dist/tools/create-spec/spec-builder.js +1 -1
- package/dist/tools/create-spec.js +42 -18
- package/dist/tools/flag-spec-gap.d.ts +1 -1
- package/dist/tools/flag-spec-gap.js +1 -1
- package/dist/tools/generate-dashboard.js +3 -3
- package/dist/tools/housekeeping-sweep.js +16 -0
- package/dist/tools/init-project/git-setup.js +11 -2
- package/dist/tools/init-project/handler.js +1 -27
- package/dist/tools/init-project/migration-runner.js +8 -0
- package/dist/tools/license-gate.d.ts +1 -0
- package/dist/tools/license-gate.js +5 -1
- package/dist/tools/list-specs.js +13 -0
- package/dist/tools/register-sdd-tools.d.ts +1 -1
- package/dist/tools/register-sdd-tools.js +1 -0
- package/dist/tools/register-spec-tools/core-spec-tools.js +16 -0
- package/dist/tools/spec-lock-handler.js +1 -1
- package/dist/tools/sync-spec-state-handler.js +7 -0
- package/dist/tools/tool-registry/group-misc.js +4 -4
- package/dist/tools/update-status/batch.d.ts +3 -0
- package/dist/tools/update-status/batch.js +96 -0
- package/dist/tools/update-status/dod-gates.js +1 -1
- package/dist/tools/update-status/file-sync.js +3 -1
- package/dist/tools/update-status/index.js +15 -2
- package/dist/tools/update-status-actions.js +2 -6
- package/dist/tools/validate.js +27 -0
- package/dist/tools/workspace-dashboard-handler.js +6 -9
- package/dist/types/git.d.ts +1 -1
- package/dist/types/spec-format.d.ts +26 -0
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,7 @@ import { resolve } from 'node:path';
|
|
|
10
10
|
import { handleCreateSpec } from '../../tools/create-spec.js';
|
|
11
11
|
import { handleListSpecs } from '../../tools/list-specs.js';
|
|
12
12
|
import { handleUpdateStatus } from '../../tools/update-status.js';
|
|
13
|
+
import { handleUpdateStatusBatch } from '../../tools/update-status/batch.js';
|
|
13
14
|
import { formatToolResult } from '../formatter.js';
|
|
14
15
|
import { detectProjectId } from '../project-detector.js';
|
|
15
16
|
import { bold, cyan, green, red, dim } from '../colors.js';
|
|
@@ -178,12 +179,15 @@ async function runStatus(args, flags) {
|
|
|
178
179
|
options: {
|
|
179
180
|
'project-id': { type: 'string' },
|
|
180
181
|
notes: { type: 'string', short: 'n' },
|
|
182
|
+
batch: { type: 'boolean' },
|
|
183
|
+
set: { type: 'string', short: 's' },
|
|
181
184
|
},
|
|
182
185
|
strict: false,
|
|
183
186
|
allowPositionals: true,
|
|
184
187
|
});
|
|
188
|
+
const batch = values.batch === true;
|
|
185
189
|
const specId = positionals[0];
|
|
186
|
-
const status = positionals[1];
|
|
190
|
+
const status = batch ? values.set : positionals[1];
|
|
187
191
|
if (!specId) {
|
|
188
192
|
process.stderr.write(`${red('Error:')} Spec ID is required.\nUsage: planu spec status SPEC-NNN <status>\n`);
|
|
189
193
|
process.exitCode = 1;
|
|
@@ -195,6 +199,21 @@ async function runStatus(args, flags) {
|
|
|
195
199
|
return;
|
|
196
200
|
}
|
|
197
201
|
const projectId = values['project-id'] ?? detectProjectId();
|
|
202
|
+
if (batch) {
|
|
203
|
+
const result = await handleUpdateStatusBatch({
|
|
204
|
+
specIds: positionals,
|
|
205
|
+
projectId,
|
|
206
|
+
status: status,
|
|
207
|
+
reviewNotes: values.notes ?? undefined,
|
|
208
|
+
});
|
|
209
|
+
if (result.isError) {
|
|
210
|
+
process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
process.stdout.write(formatToolResult(result, flags) + '\n');
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
198
217
|
const result = await handleUpdateStatus({
|
|
199
218
|
specId,
|
|
200
219
|
projectId,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// cli/commands/status.ts — planu status <specId> [--set implementing] (SPEC-124)
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { handleUpdateStatus } from '../../tools/update-status.js';
|
|
4
|
+
import { handleUpdateStatusBatch } from '../../tools/update-status/batch.js';
|
|
4
5
|
import { formatToolResult } from '../formatter.js';
|
|
5
6
|
import { detectProjectId } from '../project-detector.js';
|
|
6
7
|
import { red, green } from '../colors.js';
|
|
7
8
|
export const statusCommand = {
|
|
8
9
|
name: 'status',
|
|
9
10
|
description: 'Update the status of a spec',
|
|
10
|
-
usage: 'planu status <specId> --set <status> [--project-id ID]',
|
|
11
|
+
usage: 'planu status <specId> --set <status> [--project-id ID] | planu status batch --set <status> SPEC-001 SPEC-002',
|
|
11
12
|
async run(args, flags) {
|
|
12
13
|
const { values, positionals } = parseArgs({
|
|
13
14
|
args,
|
|
@@ -19,6 +20,7 @@ export const statusCommand = {
|
|
|
19
20
|
strict: false,
|
|
20
21
|
allowPositionals: true,
|
|
21
22
|
});
|
|
23
|
+
const isBatch = positionals[0] === 'batch';
|
|
22
24
|
const specId = positionals[0];
|
|
23
25
|
if (!specId) {
|
|
24
26
|
process.stderr.write(`${red('Error:')} Spec ID is required.\nUsage: ${statusCommand.usage}\n`);
|
|
@@ -32,6 +34,21 @@ export const statusCommand = {
|
|
|
32
34
|
return;
|
|
33
35
|
}
|
|
34
36
|
const projectId = values['project-id'] ?? detectProjectId();
|
|
37
|
+
if (isBatch) {
|
|
38
|
+
const result = await handleUpdateStatusBatch({
|
|
39
|
+
specIds: positionals.slice(1),
|
|
40
|
+
projectId,
|
|
41
|
+
status: status,
|
|
42
|
+
reviewNotes: values.notes ?? undefined,
|
|
43
|
+
});
|
|
44
|
+
if (result.isError) {
|
|
45
|
+
process.stderr.write(`${red(formatToolResult(result, flags))}\n`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
process.stdout.write(formatToolResult(result, flags) + '\n');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
35
52
|
const result = await handleUpdateStatus({
|
|
36
53
|
specId,
|
|
37
54
|
projectId,
|
|
@@ -8,6 +8,7 @@ const SESSION_START_COMMAND = [
|
|
|
8
8
|
].join(' ');
|
|
9
9
|
const STOP_COMMAND = [
|
|
10
10
|
'if [ -d "planu/" ]; then',
|
|
11
|
+
' if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then exit 0; fi;',
|
|
11
12
|
' git add planu/specs/ planu/conventions.json 2>/dev/null;',
|
|
12
13
|
' git diff --staged --quiet || git commit -m "chore: auto-save planu state" 2>/dev/null;',
|
|
13
14
|
'fi',
|
|
@@ -17,7 +17,10 @@ const HOOK_SCRIPT_SESSION_START = [
|
|
|
17
17
|
].join('\n');
|
|
18
18
|
const HOOK_SCRIPT_SESSION_END = [
|
|
19
19
|
'#!/usr/bin/env bash',
|
|
20
|
-
'# session-end.sh — auto-commit planu/ changes',
|
|
20
|
+
'# session-end.sh — optionally auto-commit planu/ changes',
|
|
21
|
+
'if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then',
|
|
22
|
+
' exit 0',
|
|
23
|
+
'fi',
|
|
21
24
|
'if git diff --quiet HEAD -- planu/ 2>/dev/null; then',
|
|
22
25
|
' exit 0',
|
|
23
26
|
'fi',
|
|
@@ -18,6 +18,7 @@ const POST_TOOL_SCRIPT = [
|
|
|
18
18
|
const STOP_SCRIPT = [
|
|
19
19
|
'planu session_checkpoint --auto 2>/dev/null;',
|
|
20
20
|
'if [ -d "planu/" ]; then',
|
|
21
|
+
' if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then exit 0; fi;',
|
|
21
22
|
' git add planu/specs/ planu/conventions.json 2>/dev/null;',
|
|
22
23
|
' git diff --staged --quiet || git commit -m "chore: auto-save planu state [kiro]" 2>/dev/null;',
|
|
23
24
|
'fi',
|
|
@@ -40,7 +41,7 @@ export function generateKiroHooks() {
|
|
|
40
41
|
{
|
|
41
42
|
id: 'planu-stop',
|
|
42
43
|
event: 'stop',
|
|
43
|
-
description: 'Save session checkpoint and auto-commit planu state on session end',
|
|
44
|
+
description: 'Save session checkpoint and optionally auto-commit planu state on session end',
|
|
44
45
|
script: STOP_SCRIPT,
|
|
45
46
|
},
|
|
46
47
|
];
|
|
@@ -202,25 +202,16 @@ const ACTION_HANDLERS = {
|
|
|
202
202
|
const specs = await specStore.listSpecs(ctx.projectId);
|
|
203
203
|
const { generateChangelog } = await import('../doc-generator/support-generators.js');
|
|
204
204
|
const doc = generateChangelog(specs, 'en');
|
|
205
|
-
//
|
|
205
|
+
// Generated changelog is returned to callers; strict Planu mode forbids planu/CHANGELOG.md.
|
|
206
206
|
const { writeFile, mkdir } = await import('node:fs/promises');
|
|
207
207
|
const { join } = await import('node:path');
|
|
208
|
-
const
|
|
209
|
-
|
|
208
|
+
const { projectDataDir } = await import('../../storage/base-store.js');
|
|
209
|
+
const changelogPath = join(projectDataDir(ctx.projectId), 'generated', 'CHANGELOG.md');
|
|
210
|
+
await mkdir(join(projectDataDir(ctx.projectId), 'generated'), { recursive: true });
|
|
210
211
|
await writeFile(changelogPath, doc.content, 'utf-8');
|
|
211
|
-
// Auto-commit planu/ changes so CHANGELOG.md is never left unstaged
|
|
212
|
-
void (async () => {
|
|
213
|
-
try {
|
|
214
|
-
const { planuAutoCommit } = await import('../git/planu-autocommit.js');
|
|
215
|
-
await planuAutoCommit({ projectPath: ctx.projectPath, reason: 'sync-release' });
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
/* best-effort */
|
|
219
|
-
}
|
|
220
|
-
})();
|
|
221
212
|
return {
|
|
222
213
|
success: true,
|
|
223
|
-
summary: `Changelog written to
|
|
214
|
+
summary: `Changelog written to external Planu project data (${doc.content.length} chars)`,
|
|
224
215
|
durationMs: 0,
|
|
225
216
|
};
|
|
226
217
|
}),
|
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
// @crash-shield-ignore-file — config/cache reader for Planu-controlled JSON; writer is this codebase, shape guaranteed by build/seed.
|
|
2
|
-
// engine/autopilot/state-updater.ts — SPEC-459: Auto-update
|
|
2
|
+
// engine/autopilot/state-updater.ts — SPEC-459: Auto-update external project status on every status change
|
|
3
3
|
// Fire-and-forget: never blocks the event pipeline, never throws to callers.
|
|
4
4
|
// SPEC-753: File-lock applied to all writes; self-healing on corrupt reads.
|
|
5
5
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
6
6
|
import { join, dirname } from 'node:path';
|
|
7
7
|
import { withStatusLock } from '../../storage/status-store/file-lock.js';
|
|
8
|
-
import { validateStatusJson,
|
|
8
|
+
import { validateStatusJson, rebuildStatusFromFrontmatters, writeRebuiltStatus, } from '../../storage/status-store/self-healing.js';
|
|
9
9
|
import { syncVersionField } from '../../storage/status-store/version-sync.js';
|
|
10
|
-
|
|
10
|
+
import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
|
|
11
|
+
const STATUS_FILENAME = 'status.json';
|
|
11
12
|
const MAX_RECENT_CHANGES = 20;
|
|
13
|
+
function getStatusPath(projectPath) {
|
|
14
|
+
return join(projectDataDir(hashProjectPath(projectPath)), STATUS_FILENAME);
|
|
15
|
+
}
|
|
12
16
|
/**
|
|
13
17
|
* SPEC-753: Load status.json with self-healing.
|
|
14
18
|
* If corrupt: quarantine → rebuild from frontmatters → return fresh state.
|
|
15
19
|
*/
|
|
16
20
|
async function loadStatus(projectPath) {
|
|
17
|
-
const filePath =
|
|
21
|
+
const filePath = getStatusPath(projectPath);
|
|
18
22
|
// SPEC-753: Validate before parsing
|
|
19
23
|
const validation = await validateStatusJson(filePath);
|
|
20
24
|
if (!validation.ok && validation.error !== 'ENOENT') {
|
|
21
|
-
// Corrupt
|
|
25
|
+
// Corrupt external state is rebuilt in place; no project-tree quarantine.
|
|
22
26
|
try {
|
|
23
|
-
await quarantineCorruptStatus(filePath, projectPath);
|
|
24
27
|
const rebuilt = await rebuildStatusFromFrontmatters({ projectPath });
|
|
25
28
|
await writeRebuiltStatus(filePath, rebuilt);
|
|
26
29
|
return rebuilt;
|
|
@@ -48,7 +51,7 @@ async function loadStatus(projectPath) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
async function saveStatus(projectPath, status) {
|
|
51
|
-
const filePath =
|
|
54
|
+
const filePath = getStatusPath(projectPath);
|
|
52
55
|
await mkdir(dirname(filePath), { recursive: true });
|
|
53
56
|
await writeFile(filePath, JSON.stringify(status, null, 2), 'utf-8');
|
|
54
57
|
}
|
|
@@ -58,7 +61,7 @@ async function saveStatus(projectPath, status) {
|
|
|
58
61
|
* SPEC-753: Wrapped in file-lock to prevent concurrent write corruption.
|
|
59
62
|
*/
|
|
60
63
|
export async function recordStatusChange(projectPath, _projectId, specId, fromStatus, toStatus) {
|
|
61
|
-
const filePath =
|
|
64
|
+
const filePath = getStatusPath(projectPath);
|
|
62
65
|
try {
|
|
63
66
|
await withStatusLock(filePath, async () => {
|
|
64
67
|
const status = await loadStatus(projectPath);
|
|
@@ -91,7 +94,7 @@ export async function recordStatusChange(projectPath, _projectId, specId, fromSt
|
|
|
91
94
|
* SPEC-753: Wrapped in file-lock.
|
|
92
95
|
*/
|
|
93
96
|
export async function refreshProjectStatus(projectPath, projectId) {
|
|
94
|
-
const filePath =
|
|
97
|
+
const filePath = getStatusPath(projectPath);
|
|
95
98
|
try {
|
|
96
99
|
await withStatusLock(filePath, async () => {
|
|
97
100
|
const { specStore } = await import('../../storage/index.js');
|
|
@@ -124,7 +127,7 @@ export async function refreshProjectStatus(projectPath, projectId) {
|
|
|
124
127
|
* SPEC-753: Wrapped in file-lock.
|
|
125
128
|
*/
|
|
126
129
|
export async function incrementSpecCount(projectPath) {
|
|
127
|
-
const filePath =
|
|
130
|
+
const filePath = getStatusPath(projectPath);
|
|
128
131
|
try {
|
|
129
132
|
await withStatusLock(filePath, async () => {
|
|
130
133
|
const status = await loadStatus(projectPath);
|
|
@@ -5,6 +5,9 @@ async function handler(ctx) {
|
|
|
5
5
|
if (!projectPath) {
|
|
6
6
|
return;
|
|
7
7
|
}
|
|
8
|
+
if (process.env.PLANU_ENABLE_AUTOCOMMIT !== 'true') {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
8
11
|
// Guard: only auto-stage when validateScore is non-null (matches original inline guard)
|
|
9
12
|
if (validateScore === null) {
|
|
10
13
|
return;
|
|
@@ -13,7 +13,7 @@ export const htmlRegenHook = {
|
|
|
13
13
|
id: 'html-regen',
|
|
14
14
|
triggers: ['done'], // only on done
|
|
15
15
|
requiresProjectPath: true,
|
|
16
|
-
label: '
|
|
16
|
+
label: 'Run legacy dashboard compatibility hook without writing project HTML',
|
|
17
17
|
handler,
|
|
18
18
|
};
|
|
19
19
|
//# sourceMappingURL=html-regen.hook.js.map
|
|
@@ -28,7 +28,7 @@ export const statusJsonHook = {
|
|
|
28
28
|
id: 'status-json',
|
|
29
29
|
triggers: [], // any status change + release_completed
|
|
30
30
|
requiresProjectPath: true,
|
|
31
|
-
label: 'Auto-update
|
|
31
|
+
label: 'Auto-update external project status with spec counts (SPEC-459/776)',
|
|
32
32
|
handler,
|
|
33
33
|
};
|
|
34
34
|
//# sourceMappingURL=status-json.hook.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DriftReport } from '../../types/cascade-hooks.js';
|
|
2
2
|
/**
|
|
3
|
-
* SPEC-776: Compare in-memory spec counts vs
|
|
3
|
+
* SPEC-776: Compare in-memory spec counts vs external status.json and
|
|
4
4
|
* recently-done spec vs planu/session-context.md.
|
|
5
5
|
* Returns a DriftReport with any detected mismatches.
|
|
6
6
|
*/
|
|
@@ -1,32 +1,35 @@
|
|
|
1
1
|
// engine/cascade-hooks/state-drift-detector.ts — SPEC-776
|
|
2
|
-
// Detects drift between in-memory spec store and
|
|
2
|
+
// Detects drift between in-memory spec store and canonical Planu state files.
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { specStore } from '../../storage/index.js';
|
|
6
|
-
import { hashProjectPath } from '../../storage/base-store.js';
|
|
7
|
-
|
|
6
|
+
import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
|
|
7
|
+
function externalStatusPath(projectPath) {
|
|
8
|
+
return join(projectDataDir(hashProjectPath(projectPath)), 'status.json');
|
|
9
|
+
}
|
|
10
|
+
/** Read and parse external status counts; returns null if missing or invalid. */
|
|
8
11
|
async function readStatusJsonDoneCounts(projectPath) {
|
|
9
12
|
try {
|
|
10
|
-
const raw = await readFile(
|
|
13
|
+
const raw = await readFile(externalStatusPath(projectPath), 'utf-8');
|
|
11
14
|
const parsed = JSON.parse(raw);
|
|
12
15
|
if (parsed === null || typeof parsed !== 'object') {
|
|
13
16
|
return null;
|
|
14
17
|
}
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
18
|
+
const byStatus = parsed.byStatus;
|
|
19
|
+
if (byStatus === null || typeof byStatus !== 'object') {
|
|
17
20
|
return null;
|
|
18
21
|
}
|
|
19
|
-
const doneVal =
|
|
22
|
+
const doneVal = byStatus.done;
|
|
20
23
|
return typeof doneVal === 'number' ? doneVal : null;
|
|
21
24
|
}
|
|
22
25
|
catch {
|
|
23
26
|
return null;
|
|
24
27
|
}
|
|
25
28
|
}
|
|
26
|
-
/** Returns true if
|
|
29
|
+
/** Returns true if external status.json exists (even if counts are missing). */
|
|
27
30
|
async function statusJsonExists(projectPath) {
|
|
28
31
|
try {
|
|
29
|
-
await readFile(
|
|
32
|
+
await readFile(externalStatusPath(projectPath), 'utf-8');
|
|
30
33
|
return true;
|
|
31
34
|
}
|
|
32
35
|
catch {
|
|
@@ -57,7 +60,7 @@ function extractLastDoneSpecFromContext(content) {
|
|
|
57
60
|
return idMatch ? idMatch[0] : null;
|
|
58
61
|
}
|
|
59
62
|
/**
|
|
60
|
-
* SPEC-776: Compare in-memory spec counts vs
|
|
63
|
+
* SPEC-776: Compare in-memory spec counts vs external status.json and
|
|
61
64
|
* recently-done spec vs planu/session-context.md.
|
|
62
65
|
* Returns a DriftReport with any detected mismatches.
|
|
63
66
|
*/
|
|
@@ -72,12 +75,12 @@ export async function verifyStateFiles(projectPath) {
|
|
|
72
75
|
return { drifted: false, alerts: [] };
|
|
73
76
|
}
|
|
74
77
|
const expectedDoneCount = specs.filter((s) => s.status === 'done').length;
|
|
75
|
-
// --- Check
|
|
78
|
+
// --- Check external status.json ---
|
|
76
79
|
const exists = await statusJsonExists(projectPath);
|
|
77
80
|
if (!exists) {
|
|
78
81
|
alerts.push({
|
|
79
82
|
kind: 'state_drift',
|
|
80
|
-
message: 'state_drift:
|
|
83
|
+
message: 'state_drift: external status.json missing or unreadable',
|
|
81
84
|
severity: 'warning',
|
|
82
85
|
fix: 'reconcile_status_json(projectPath)',
|
|
83
86
|
});
|
|
@@ -4,6 +4,9 @@ import { promisify } from 'node:util';
|
|
|
4
4
|
import { access } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
const execFile = promisify(execFileCb);
|
|
7
|
+
export function isPlanuAutocommitEnabled() {
|
|
8
|
+
return process.env.PLANU_ENABLE_AUTOCOMMIT === 'true';
|
|
9
|
+
}
|
|
7
10
|
/** Run git with execFile (no shell injection risk). */
|
|
8
11
|
async function runGit(cwd, args) {
|
|
9
12
|
const result = await execFile('git', args, {
|
|
@@ -101,6 +104,9 @@ async function unstageFiles(projectPath) {
|
|
|
101
104
|
*/
|
|
102
105
|
export async function planuAutoCommit(opts) {
|
|
103
106
|
const { projectPath, specId, reason } = opts;
|
|
107
|
+
if (!isPlanuAutocommitEnabled()) {
|
|
108
|
+
return { committed: false, skipped: 'disabled' };
|
|
109
|
+
}
|
|
104
110
|
// 1. Safety check: refuse if mid-merge
|
|
105
111
|
const midMerge = await isMidMerge(projectPath);
|
|
106
112
|
if (midMerge) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// SPEC-347: auto-stage planu HTML files so they are never left out of commits
|
|
4
4
|
import { readFile, writeFile, access, constants } from 'node:fs/promises';
|
|
5
5
|
import { join, resolve } from 'node:path';
|
|
6
|
-
const PLANU_SNIPPET = `\n# Planu: auto-stage specs (SPEC-466 — only source-of-truth files)\
|
|
6
|
+
const PLANU_SNIPPET = `\n# Planu: optional auto-stage specs (SPEC-466 — only source-of-truth files)\nif [ "$PLANU_ENABLE_AUTOCOMMIT" = "true" ]; then\n git add planu/specs/ planu/conventions.json 2>/dev/null || true\nfi\n`;
|
|
7
7
|
const PLANU_MARKER = 'git add planu/';
|
|
8
8
|
async function fileExists(filePath) {
|
|
9
9
|
try {
|
|
@@ -74,8 +74,8 @@ export async function injectPlanuAutoStage(projectPath) {
|
|
|
74
74
|
}
|
|
75
75
|
// lefthook / simple-git-hooks: cannot auto-inject, return manual instructions
|
|
76
76
|
const manualInstructions = hookSystem === 'lefthook'
|
|
77
|
-
? `Add to lefthook.yml under pre-commit commands:\n - run: git add planu/specs/ planu/conventions.json 2>/dev/null || true`
|
|
78
|
-
: `Add to package.json "simple-git-hooks"."pre-commit":\n "git add planu/specs/ planu/conventions.json 2>/dev/null || true"`;
|
|
77
|
+
? `Add to lefthook.yml under pre-commit commands:\n - run: '[ "$PLANU_ENABLE_AUTOCOMMIT" = "true" ] && git add planu/specs/ planu/conventions.json 2>/dev/null || true'`
|
|
78
|
+
: `Add to package.json "simple-git-hooks"."pre-commit":\n "[ \\"$PLANU_ENABLE_AUTOCOMMIT\\" = \\"true\\" ] && git add planu/specs/ planu/conventions.json 2>/dev/null || true"`;
|
|
79
79
|
return {
|
|
80
80
|
injected: false,
|
|
81
81
|
hookSystem,
|
|
@@ -7,6 +7,7 @@ import { acquireLock, releaseLock } from '../safety/cross-process-lock.js';
|
|
|
7
7
|
import { appendTransitionEvent } from '../../storage/transition-log.js';
|
|
8
8
|
import { validateArtifact } from './schemas.js';
|
|
9
9
|
import { resolveCompat } from './version-policy.js';
|
|
10
|
+
import { projectDataDir } from '../../storage/base-store.js';
|
|
10
11
|
// Current schema version for version-policy checks
|
|
11
12
|
const CURRENT_SCHEMA_VERSION = '1.0.0';
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
@@ -14,14 +15,14 @@ const CURRENT_SCHEMA_VERSION = '1.0.0';
|
|
|
14
15
|
// ---------------------------------------------------------------------------
|
|
15
16
|
/**
|
|
16
17
|
* Returns the file path for a handoff artifact.
|
|
17
|
-
* Layout: planu/data/projects/<projectId>/handoffs/<specId>/<kind>
|
|
18
|
+
* Layout: ~/.planu/data/projects/<projectId>/handoffs/<specId>/<kind>
|
|
18
19
|
*
|
|
19
20
|
* Note: kind values like 'spec.lock', 'review_feedback', etc. are used directly as filenames.
|
|
20
21
|
*/
|
|
21
22
|
function artifactPath(projectId, specId, kind) {
|
|
22
23
|
// review_feedback is a .md file, rest are .json
|
|
23
24
|
const filename = kind === 'review_feedback' ? 'review_feedback.md' : `${kind}.json`;
|
|
24
|
-
return join(
|
|
25
|
+
return join(projectDataDir(projectId), 'handoffs', specId, filename);
|
|
25
26
|
}
|
|
26
27
|
/** Map artifact kind to the transition-log eventType */
|
|
27
28
|
function eventTypeForKind(kind) {
|
|
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
|
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { stripFrontmatter } from './frontmatter-parser.js';
|
|
6
|
+
import { hashProjectPath, projectDataDir } from '../storage/base-store.js';
|
|
6
7
|
// ── Parsing helpers ──────────────────────────────────────────────────────────
|
|
7
8
|
async function safeReadFile(path) {
|
|
8
9
|
if (!path) {
|
|
@@ -293,7 +294,7 @@ async function persistHandoffIfPossible(pkg, knowledge) {
|
|
|
293
294
|
if (!knowledge.projectPath) {
|
|
294
295
|
return pkg;
|
|
295
296
|
}
|
|
296
|
-
const handoffDir = join(knowledge.projectPath, '
|
|
297
|
+
const handoffDir = join(projectDataDir(hashProjectPath(knowledge.projectPath)), 'handoffs');
|
|
297
298
|
const handoffPath = join(handoffDir, `${pkg.specId}.md`);
|
|
298
299
|
const sessionContextPath = join(knowledge.projectPath, 'planu', 'session-context.md');
|
|
299
300
|
const markdown = renderPersistedHandoff(pkg);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FullSpectrumHookConfig, HookScript } from '../../types/hooks-advanced.js';
|
|
2
2
|
/**
|
|
3
|
-
* Generates the Stop hook script
|
|
3
|
+
* Generates the Stop hook script. Planu housekeeping is opt-in so generated
|
|
4
|
+
* hooks cannot silently stage or commit user branches.
|
|
4
5
|
*/
|
|
5
6
|
export declare function generateStopHookScript(projectPath: string): string;
|
|
6
7
|
/**
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
// Individual script builders
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
/**
|
|
6
|
-
* Generates the Stop hook script
|
|
6
|
+
* Generates the Stop hook script. Planu housekeeping is opt-in so generated
|
|
7
|
+
* hooks cannot silently stage or commit user branches.
|
|
7
8
|
*/
|
|
8
9
|
export function generateStopHookScript(projectPath) {
|
|
9
10
|
return `#!/bin/bash
|
|
10
|
-
#
|
|
11
|
+
# Optionally auto-commit planu/ if explicitly enabled (Planu Stop hook)
|
|
11
12
|
cd "${projectPath}"
|
|
13
|
+
if [ "$PLANU_ENABLE_AUTOCOMMIT" != "true" ]; then exit 0; fi
|
|
12
14
|
if git diff --quiet planu/ 2>/dev/null; then exit 0; fi
|
|
13
15
|
git add planu/specs/ planu/conventions.json && git commit -m "chore(planu): auto-commit session state [skip ci]" --no-verify
|
|
14
16
|
`;
|
|
@@ -73,7 +75,7 @@ export function generateFullSpectrumHooks(config, projectPath = '$CLAUDE_PROJECT
|
|
|
73
75
|
scripts.push({
|
|
74
76
|
eventType: 'Stop',
|
|
75
77
|
scriptContent: generateStopHookScript(projectPath),
|
|
76
|
-
description: '
|
|
78
|
+
description: 'Optionally auto-commits planu/ changes at session end when enabled.',
|
|
77
79
|
});
|
|
78
80
|
}
|
|
79
81
|
if (config.preCompactHook) {
|
|
@@ -6,7 +6,7 @@ export interface PostmortemInput {
|
|
|
6
6
|
projectRoot?: string;
|
|
7
7
|
}
|
|
8
8
|
/**
|
|
9
|
-
* Write a post-mortem skeleton to
|
|
9
|
+
* Write a post-mortem skeleton to external Planu project data.
|
|
10
10
|
* Returns the path of the written file.
|
|
11
11
|
*/
|
|
12
12
|
export declare function writePostmortem(input: PostmortemInput): Promise<string>;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// engine/release/postmortem-generator.ts — SPEC-737: Post-mortem skeleton writer
|
|
2
2
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
5
6
|
// Template path
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
@@ -12,7 +13,7 @@ function templatePath() {
|
|
|
12
13
|
// Public API
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
/**
|
|
15
|
-
* Write a post-mortem skeleton to
|
|
16
|
+
* Write a post-mortem skeleton to external Planu project data.
|
|
16
17
|
* Returns the path of the written file.
|
|
17
18
|
*/
|
|
18
19
|
export async function writePostmortem(input) {
|
|
@@ -20,7 +21,7 @@ export async function writePostmortem(input) {
|
|
|
20
21
|
const isoNow = new Date().toISOString();
|
|
21
22
|
const tsSlug = isoNow.replace(/[:.]/g, '-').replace('T', '_').slice(0, 19);
|
|
22
23
|
const filename = `${version}-${tsSlug}.md`;
|
|
23
|
-
const outDir = join(projectRoot, '
|
|
24
|
+
const outDir = join(projectDataDir(hashProjectPath(projectRoot)), 'research', 'postmortems');
|
|
24
25
|
const outPath = join(outDir, filename);
|
|
25
26
|
// Load template
|
|
26
27
|
let template;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// SMB / NFSv3 may produce false acquires because O_EXCL is not guaranteed atomic on
|
|
8
8
|
// network filesystems. For local-disk POSIX (HFS+, APFS, ext4, tmpfs) this is reliable.
|
|
9
9
|
//
|
|
10
|
-
// Lockfiles live at: <projectPath>/
|
|
10
|
+
// Lockfiles live at: <projectPath>/data/.locks/planu/<specId>.lock
|
|
11
11
|
// File mode: 0o600 (owner read/write only)
|
|
12
12
|
import { open, unlink, readFile, mkdir, chmod } from 'node:fs/promises';
|
|
13
13
|
import { existsSync } from 'node:fs';
|
|
@@ -41,7 +41,7 @@ export class LockBusyError extends Error {
|
|
|
41
41
|
// Internal helpers
|
|
42
42
|
// ---------------------------------------------------------------------------
|
|
43
43
|
function locksDir(projectPath) {
|
|
44
|
-
return join(projectPath, '
|
|
44
|
+
return join(projectPath, 'data', '.locks', 'planu');
|
|
45
45
|
}
|
|
46
46
|
function lockPath(projectPath, specId) {
|
|
47
47
|
return join(locksDir(projectPath), `${specId}.lock`);
|
|
@@ -74,7 +74,6 @@ export async function writeCheckpoint(projectPath, snapshot) {
|
|
|
74
74
|
const updated = replaceLatestCheckpoint(existing, newBlock);
|
|
75
75
|
await mkdir(planuDir, { recursive: true });
|
|
76
76
|
await writeFile(filePath, updated, 'utf8');
|
|
77
|
-
// Auto-commit planu/ changes so session-context.md is never left unstaged
|
|
78
77
|
void (async () => {
|
|
79
78
|
try {
|
|
80
79
|
const { planuAutoCommit } = await import('../git/planu-autocommit.js');
|
|
@@ -132,7 +132,10 @@ export async function generateSessionContext(projectPath, projectId) {
|
|
|
132
132
|
await mkdir(planuDir, { recursive: true });
|
|
133
133
|
const contextPath = join(planuDir, 'session-context.md');
|
|
134
134
|
await writeFile(contextPath, content, 'utf-8');
|
|
135
|
-
|
|
135
|
+
if (process.env.PLANU_ENABLE_AUTOCOMMIT !== 'true') {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// SPEC-598: opt-in atomic commit for hosts that explicitly enable Planu autocommit.
|
|
136
139
|
try {
|
|
137
140
|
const { execFile } = await import('node:child_process');
|
|
138
141
|
const { promisify } = await import('node:util');
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Orchestrates the two-tier spec drift audit.
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { listSpecs } from '../../storage/spec-store.js';
|
|
5
|
-
import { hashProjectPath } from '../../storage/base-store.js';
|
|
5
|
+
import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
|
|
6
6
|
import { buildSpecGraph } from '../spec-dependency-graph/graph-builder.js';
|
|
7
7
|
import { getSupersededSet, getSupersededMap } from '../spec-dependency-graph/superseded-set.js';
|
|
8
8
|
import { scanSpecTier1 } from './tier1-scanner.js';
|
|
@@ -61,7 +61,7 @@ export async function auditSpecsDrift(opts) {
|
|
|
61
61
|
}
|
|
62
62
|
// Generate report path
|
|
63
63
|
const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
|
|
64
|
-
const reportPath = join(
|
|
64
|
+
const reportPath = join(projectDataDir(projectId), 'research', `audit-full-${ts}.md`);
|
|
65
65
|
const report = {
|
|
66
66
|
generatedAt: new Date().toISOString(),
|
|
67
67
|
totalSpecs: allSpecs.length,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AuditReport, DriftReviewPendingEntry } from '../../types/spec-audit.js';
|
|
2
2
|
export declare function buildMarkdownReport(report: AuditReport): string;
|
|
3
|
-
export declare function writeAuditReport(report: AuditReport,
|
|
3
|
+
export declare function writeAuditReport(report: AuditReport, _projectRoot: string): Promise<void>;
|
|
4
4
|
export declare function appendDriftReviewToPending(entry: DriftReviewPendingEntry, projectRoot: string): Promise<void>;
|
|
5
5
|
//# sourceMappingURL=report-writer.d.ts.map
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// engine/spec-audit/report-writer.ts — SPEC-744
|
|
2
|
-
// Generates the prioritised markdown drift report and appends pending
|
|
2
|
+
// Generates the prioritised markdown drift report and appends pending review entries.
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join, dirname } from 'node:path';
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { atomicWriteFile } from '../safety/atomic-write-file.js';
|
|
7
|
+
import { hashProjectPath, projectDataDir } from '../../storage/base-store.js';
|
|
7
8
|
// ---------------------------------------------------------------------------
|
|
8
9
|
// Markdown report
|
|
9
10
|
// ---------------------------------------------------------------------------
|
|
@@ -57,8 +58,8 @@ export function buildMarkdownReport(report) {
|
|
|
57
58
|
// ---------------------------------------------------------------------------
|
|
58
59
|
// Write report atomically
|
|
59
60
|
// ---------------------------------------------------------------------------
|
|
60
|
-
export async function writeAuditReport(report,
|
|
61
|
-
const dir =
|
|
61
|
+
export async function writeAuditReport(report, _projectRoot) {
|
|
62
|
+
const dir = dirname(report.reportPath);
|
|
62
63
|
await mkdir(dir, { recursive: true });
|
|
63
64
|
const content = buildMarkdownReport(report);
|
|
64
65
|
await atomicWriteFile(report.reportPath, content);
|
|
@@ -67,7 +68,7 @@ export async function writeAuditReport(report, projectRoot) {
|
|
|
67
68
|
// Append to pending.json
|
|
68
69
|
// ---------------------------------------------------------------------------
|
|
69
70
|
export async function appendDriftReviewToPending(entry, projectRoot) {
|
|
70
|
-
const pendingPath = join(projectRoot, '
|
|
71
|
+
const pendingPath = join(projectDataDir(hashProjectPath(projectRoot)), 'pending.json');
|
|
71
72
|
let existing = [];
|
|
72
73
|
if (existsSync(pendingPath)) {
|
|
73
74
|
try {
|