cclaw-cli 0.48.15 → 0.48.17
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/dist/content/flow-map.js +5 -0
- package/dist/content/hook-events.d.ts +7 -2
- package/dist/content/hook-events.js +30 -46
- package/dist/content/hook-manifest.d.ts +82 -0
- package/dist/content/hook-manifest.js +208 -0
- package/dist/content/node-hooks.js +148 -13
- package/dist/content/observe.d.ts +16 -0
- package/dist/content/observe.js +65 -120
- package/dist/internal/advance-stage.js +6 -2
- package/dist/internal/hook-manifest.d.ts +16 -0
- package/dist/internal/hook-manifest.js +77 -0
- package/dist/run-archive.js +145 -138
- package/dist/run-persistence.d.ts +13 -0
- package/dist/run-persistence.js +16 -2
- package/package.json +1 -1
package/dist/content/observe.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RUNTIME_ROOT } from "../constants.js";
|
|
2
|
+
import { HOOK_MANIFEST, groupBindingsByEvent } from "./hook-manifest.js";
|
|
2
3
|
function hookDispatcherCommand(hookName) {
|
|
3
4
|
// RUNTIME_ROOT is a relative path (".cclaw") that currently contains no
|
|
4
5
|
// whitespace, so quoting is unnecessary inside the JSON-encoded command
|
|
@@ -6,139 +7,83 @@ function hookDispatcherCommand(hookName) {
|
|
|
6
7
|
// JSON.stringify to survive spaces.
|
|
7
8
|
return `node ${RUNTIME_ROOT}/hooks/run-hook.mjs ${hookName}`;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Claude / Codex share the same outer envelope: each event is an
|
|
12
|
+
* array of `{matcher?, hooks: [{type: "command", command, timeout?}]}`
|
|
13
|
+
* objects. Entries with the same `matcher` are merged into a single
|
|
14
|
+
* outer entry so we emit one `{matcher: "..."}` block with multiple
|
|
15
|
+
* inner hook commands.
|
|
16
|
+
*/
|
|
17
|
+
function buildClaudeLikeEvents(harness) {
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const group of groupBindingsByEvent(harness)) {
|
|
20
|
+
const mergedByMatcher = new Map();
|
|
21
|
+
const order = [];
|
|
22
|
+
for (const entry of group.entries) {
|
|
23
|
+
const matcherKey = entry.matcher ?? "__no_matcher__";
|
|
24
|
+
let bucket = mergedByMatcher.get(matcherKey);
|
|
25
|
+
if (!bucket) {
|
|
26
|
+
bucket = {
|
|
27
|
+
...(entry.matcher !== undefined ? { matcher: entry.matcher } : {}),
|
|
28
|
+
hooks: []
|
|
29
|
+
};
|
|
30
|
+
mergedByMatcher.set(matcherKey, bucket);
|
|
31
|
+
order.push(matcherKey);
|
|
32
|
+
}
|
|
33
|
+
const hookEntry = {
|
|
34
|
+
type: "command",
|
|
35
|
+
command: hookDispatcherCommand(entry.handler),
|
|
36
|
+
...(entry.timeout !== undefined ? { timeout: entry.timeout } : {})
|
|
37
|
+
};
|
|
38
|
+
bucket.hooks.push(hookEntry);
|
|
39
|
+
}
|
|
40
|
+
out[group.event] = order.map((key) => mergedByMatcher.get(key));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cursor uses a flat shape: each event maps directly to an array of
|
|
46
|
+
* `{command, matcher?, timeout?}` entries — no inner `hooks` array.
|
|
47
|
+
*/
|
|
48
|
+
function buildCursorEvents() {
|
|
49
|
+
const out = {};
|
|
50
|
+
for (const group of groupBindingsByEvent("cursor")) {
|
|
51
|
+
out[group.event] = group.entries.map((entry) => ({
|
|
52
|
+
command: hookDispatcherCommand(entry.handler),
|
|
53
|
+
...(entry.matcher !== undefined ? { matcher: entry.matcher } : {}),
|
|
54
|
+
...(entry.timeout !== undefined ? { timeout: entry.timeout } : {})
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
9
59
|
export function claudeHooksJsonWithObservation() {
|
|
10
60
|
return JSON.stringify({
|
|
11
61
|
cclawHookSchemaVersion: 1,
|
|
12
|
-
hooks:
|
|
13
|
-
SessionStart: [{
|
|
14
|
-
matcher: "startup|resume|clear|compact",
|
|
15
|
-
hooks: [{
|
|
16
|
-
type: "command",
|
|
17
|
-
command: hookDispatcherCommand("session-start")
|
|
18
|
-
}]
|
|
19
|
-
}],
|
|
20
|
-
PreToolUse: [{
|
|
21
|
-
matcher: "*",
|
|
22
|
-
hooks: [{
|
|
23
|
-
type: "command",
|
|
24
|
-
command: hookDispatcherCommand("prompt-guard")
|
|
25
|
-
}]
|
|
26
|
-
}, {
|
|
27
|
-
matcher: "Write|Edit|MultiEdit|NotebookEdit|Bash",
|
|
28
|
-
hooks: [{
|
|
29
|
-
type: "command",
|
|
30
|
-
command: hookDispatcherCommand("workflow-guard")
|
|
31
|
-
}]
|
|
32
|
-
}],
|
|
33
|
-
PostToolUse: [{
|
|
34
|
-
matcher: "*",
|
|
35
|
-
hooks: [{
|
|
36
|
-
type: "command",
|
|
37
|
-
command: hookDispatcherCommand("context-monitor")
|
|
38
|
-
}]
|
|
39
|
-
}],
|
|
40
|
-
Stop: [{
|
|
41
|
-
hooks: [{
|
|
42
|
-
type: "command",
|
|
43
|
-
command: hookDispatcherCommand("stop-checkpoint"),
|
|
44
|
-
timeout: 10
|
|
45
|
-
}]
|
|
46
|
-
}],
|
|
47
|
-
PreCompact: [{
|
|
48
|
-
matcher: "manual|auto",
|
|
49
|
-
hooks: [{
|
|
50
|
-
type: "command",
|
|
51
|
-
command: hookDispatcherCommand("pre-compact"),
|
|
52
|
-
timeout: 10
|
|
53
|
-
}]
|
|
54
|
-
}]
|
|
55
|
-
}
|
|
62
|
+
hooks: buildClaudeLikeEvents("claude")
|
|
56
63
|
}, null, 2);
|
|
57
64
|
}
|
|
58
65
|
export function cursorHooksJsonWithObservation() {
|
|
59
66
|
return JSON.stringify({
|
|
60
67
|
cclawHookSchemaVersion: 1,
|
|
61
68
|
version: 1,
|
|
62
|
-
hooks:
|
|
63
|
-
sessionStart: [{
|
|
64
|
-
command: hookDispatcherCommand("session-start")
|
|
65
|
-
}],
|
|
66
|
-
sessionResume: [{
|
|
67
|
-
command: hookDispatcherCommand("session-start")
|
|
68
|
-
}],
|
|
69
|
-
sessionClear: [{
|
|
70
|
-
command: hookDispatcherCommand("session-start")
|
|
71
|
-
}],
|
|
72
|
-
sessionCompact: [{
|
|
73
|
-
command: hookDispatcherCommand("pre-compact")
|
|
74
|
-
}, {
|
|
75
|
-
command: hookDispatcherCommand("session-start")
|
|
76
|
-
}],
|
|
77
|
-
preToolUse: [{
|
|
78
|
-
matcher: "*",
|
|
79
|
-
command: hookDispatcherCommand("prompt-guard")
|
|
80
|
-
}, {
|
|
81
|
-
matcher: "*",
|
|
82
|
-
command: hookDispatcherCommand("workflow-guard")
|
|
83
|
-
}],
|
|
84
|
-
postToolUse: [{
|
|
85
|
-
matcher: "*",
|
|
86
|
-
command: hookDispatcherCommand("context-monitor")
|
|
87
|
-
}],
|
|
88
|
-
stop: [{
|
|
89
|
-
command: hookDispatcherCommand("stop-checkpoint"),
|
|
90
|
-
timeout: 10
|
|
91
|
-
}]
|
|
92
|
-
}
|
|
69
|
+
hooks: buildCursorEvents()
|
|
93
70
|
}, null, 2);
|
|
94
71
|
}
|
|
95
72
|
export function codexHooksJsonWithObservation() {
|
|
96
73
|
return JSON.stringify({
|
|
97
74
|
cclawHookSchemaVersion: 1,
|
|
98
|
-
hooks:
|
|
99
|
-
SessionStart: [{
|
|
100
|
-
matcher: "startup|resume",
|
|
101
|
-
hooks: [{
|
|
102
|
-
type: "command",
|
|
103
|
-
command: hookDispatcherCommand("session-start")
|
|
104
|
-
}]
|
|
105
|
-
}],
|
|
106
|
-
UserPromptSubmit: [{
|
|
107
|
-
hooks: [{
|
|
108
|
-
type: "command",
|
|
109
|
-
command: hookDispatcherCommand("prompt-guard")
|
|
110
|
-
}, {
|
|
111
|
-
type: "command",
|
|
112
|
-
command: hookDispatcherCommand("workflow-guard")
|
|
113
|
-
}, {
|
|
114
|
-
type: "command",
|
|
115
|
-
command: hookDispatcherCommand("verify-current-state")
|
|
116
|
-
}]
|
|
117
|
-
}],
|
|
118
|
-
PreToolUse: [{
|
|
119
|
-
matcher: "Bash|bash",
|
|
120
|
-
hooks: [{
|
|
121
|
-
type: "command",
|
|
122
|
-
command: hookDispatcherCommand("prompt-guard")
|
|
123
|
-
}, {
|
|
124
|
-
type: "command",
|
|
125
|
-
command: hookDispatcherCommand("workflow-guard")
|
|
126
|
-
}]
|
|
127
|
-
}],
|
|
128
|
-
PostToolUse: [{
|
|
129
|
-
matcher: "Bash|bash",
|
|
130
|
-
hooks: [{
|
|
131
|
-
type: "command",
|
|
132
|
-
command: hookDispatcherCommand("context-monitor")
|
|
133
|
-
}]
|
|
134
|
-
}],
|
|
135
|
-
Stop: [{
|
|
136
|
-
hooks: [{
|
|
137
|
-
type: "command",
|
|
138
|
-
command: hookDispatcherCommand("stop-checkpoint"),
|
|
139
|
-
timeout: 10
|
|
140
|
-
}]
|
|
141
|
-
}]
|
|
142
|
-
}
|
|
75
|
+
hooks: buildClaudeLikeEvents("codex")
|
|
143
76
|
}, null, 2);
|
|
144
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Public accessor so diagnostic CLIs and tests can inspect the
|
|
80
|
+
* manifest without importing the private generator helpers.
|
|
81
|
+
*/
|
|
82
|
+
export function hookManifestSnapshot() {
|
|
83
|
+
return (HOOK_MANIFEST.length === 0
|
|
84
|
+
? ["claude", "cursor", "codex"]
|
|
85
|
+
: ["claude", "cursor", "codex"]).map((harness) => ({
|
|
86
|
+
harness,
|
|
87
|
+
events: groupBindingsByEvent(harness)
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
@@ -13,6 +13,7 @@ import { appendKnowledge } from "../knowledge-store.js";
|
|
|
13
13
|
import { readFlowState, writeFlowState } from "../runs.js";
|
|
14
14
|
import { FLOW_STAGES } from "../types.js";
|
|
15
15
|
import { runCompoundReadinessCommand } from "./compound-readiness.js";
|
|
16
|
+
import { runHookManifestCommand } from "./hook-manifest.js";
|
|
16
17
|
import { runEnvelopeValidateCommand } from "./envelope-validate.js";
|
|
17
18
|
import { runKnowledgeDigestCommand } from "./knowledge-digest.js";
|
|
18
19
|
import { runTddLoopStatusCommand } from "./tdd-loop-status.js";
|
|
@@ -674,7 +675,7 @@ async function runHookCommand(projectRoot, args, io) {
|
|
|
674
675
|
export async function runInternalCommand(projectRoot, argv, io) {
|
|
675
676
|
const [subcommand, ...tokens] = argv;
|
|
676
677
|
if (!subcommand) {
|
|
677
|
-
io.stderr.write("cclaw internal requires a subcommand: advance-stage | verify-flow-state-diff | verify-current-state | knowledge-digest | envelope-validate | tdd-red-evidence | tdd-loop-status | compound-readiness | hook\n");
|
|
678
|
+
io.stderr.write("cclaw internal requires a subcommand: advance-stage | verify-flow-state-diff | verify-current-state | knowledge-digest | envelope-validate | tdd-red-evidence | tdd-loop-status | compound-readiness | hook-manifest | hook\n");
|
|
678
679
|
return 1;
|
|
679
680
|
}
|
|
680
681
|
try {
|
|
@@ -702,10 +703,13 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
702
703
|
if (subcommand === "compound-readiness") {
|
|
703
704
|
return await runCompoundReadinessCommand(projectRoot, tokens, io);
|
|
704
705
|
}
|
|
706
|
+
if (subcommand === "hook-manifest") {
|
|
707
|
+
return await runHookManifestCommand(projectRoot, tokens, io);
|
|
708
|
+
}
|
|
705
709
|
if (subcommand === "hook") {
|
|
706
710
|
return await runHookCommand(projectRoot, parseHookArgs(tokens), io);
|
|
707
711
|
}
|
|
708
|
-
io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | verify-flow-state-diff | verify-current-state | knowledge-digest | envelope-validate | tdd-red-evidence | tdd-loop-status | compound-readiness | hook\n`);
|
|
712
|
+
io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | verify-flow-state-diff | verify-current-state | knowledge-digest | envelope-validate | tdd-red-evidence | tdd-loop-status | compound-readiness | hook-manifest | hook\n`);
|
|
709
713
|
return 1;
|
|
710
714
|
}
|
|
711
715
|
catch (err) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
interface InternalIo {
|
|
3
|
+
stdout: Writable;
|
|
4
|
+
stderr: Writable;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* `cclaw internal hook-manifest` — diagnostic command that prints
|
|
8
|
+
* the resolved manifest. Primary use cases:
|
|
9
|
+
*
|
|
10
|
+
* - debugging "which handler fires for event X on harness Y",
|
|
11
|
+
* - migration tooling that needs a machine-readable view,
|
|
12
|
+
* - parity verification between the source-of-truth manifest and
|
|
13
|
+
* per-harness generated documents.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runHookManifestCommand(_projectRoot: string, argv: string[], io: InternalIo): Promise<number>;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { HOOK_MANIFEST, HOOK_MANIFEST_HARNESSES, groupBindingsByEvent, requiredEventsFor } from "../content/hook-manifest.js";
|
|
2
|
+
function parseArgs(tokens) {
|
|
3
|
+
const args = { json: false };
|
|
4
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
5
|
+
const token = tokens[i];
|
|
6
|
+
if (token === "--json")
|
|
7
|
+
args.json = true;
|
|
8
|
+
else if (token === "--harness") {
|
|
9
|
+
const value = tokens[i + 1];
|
|
10
|
+
if (value !== "claude" && value !== "cursor" && value !== "codex") {
|
|
11
|
+
throw new Error(`--harness must be one of claude|cursor|codex, got ${String(value)}`);
|
|
12
|
+
}
|
|
13
|
+
args.harness = value;
|
|
14
|
+
i += 1;
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
throw new Error(`Unknown hook-manifest flag: ${token}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return args;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* `cclaw internal hook-manifest` — diagnostic command that prints
|
|
24
|
+
* the resolved manifest. Primary use cases:
|
|
25
|
+
*
|
|
26
|
+
* - debugging "which handler fires for event X on harness Y",
|
|
27
|
+
* - migration tooling that needs a machine-readable view,
|
|
28
|
+
* - parity verification between the source-of-truth manifest and
|
|
29
|
+
* per-harness generated documents.
|
|
30
|
+
*/
|
|
31
|
+
export async function runHookManifestCommand(_projectRoot, argv, io) {
|
|
32
|
+
const args = parseArgs(argv);
|
|
33
|
+
const harnesses = args.harness ? [args.harness] : [...HOOK_MANIFEST_HARNESSES];
|
|
34
|
+
if (args.json) {
|
|
35
|
+
const payload = {
|
|
36
|
+
handlers: HOOK_MANIFEST.map((spec) => ({
|
|
37
|
+
handler: spec.handler,
|
|
38
|
+
description: spec.description,
|
|
39
|
+
semantic: spec.semantic,
|
|
40
|
+
bindings: spec.bindings
|
|
41
|
+
})),
|
|
42
|
+
byHarness: Object.fromEntries(harnesses.map((harness) => [
|
|
43
|
+
harness,
|
|
44
|
+
{
|
|
45
|
+
requiredEvents: requiredEventsFor(harness),
|
|
46
|
+
events: groupBindingsByEvent(harness)
|
|
47
|
+
}
|
|
48
|
+
]))
|
|
49
|
+
};
|
|
50
|
+
io.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
const lines = [];
|
|
54
|
+
lines.push("cclaw hook manifest");
|
|
55
|
+
for (const harness of harnesses) {
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push(`## ${harness}`);
|
|
58
|
+
const groups = groupBindingsByEvent(harness);
|
|
59
|
+
if (groups.length === 0) {
|
|
60
|
+
lines.push(" (no bindings)");
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
for (const group of groups) {
|
|
64
|
+
lines.push(` ${group.event}:`);
|
|
65
|
+
for (const entry of group.entries) {
|
|
66
|
+
const parts = [entry.handler];
|
|
67
|
+
if (entry.matcher)
|
|
68
|
+
parts.push(`matcher=${entry.matcher}`);
|
|
69
|
+
if (entry.timeout)
|
|
70
|
+
parts.push(`timeout=${entry.timeout}s`);
|
|
71
|
+
lines.push(` - ${parts.join(" ")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
io.stdout.write(`${lines.join("\n")}\n`);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
package/dist/run-archive.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readActiveFeature, syncActiveFeatureSnapshot } from "./feature-system.j
|
|
|
6
6
|
import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "./fs-utils.js";
|
|
7
7
|
import { readKnowledgeSafely } from "./knowledge-store.js";
|
|
8
8
|
import { evaluateRetroGate } from "./retro-gate.js";
|
|
9
|
-
import { ensureRunSystem, readFlowState, writeFlowState } from "./run-persistence.js";
|
|
9
|
+
import { ensureRunSystem, flowStateLockPathFor, readFlowState, writeFlowState } from "./run-persistence.js";
|
|
10
10
|
const RUNS_DIR_REL_PATH = `${RUNTIME_ROOT}/runs`;
|
|
11
11
|
const ACTIVE_ARTIFACTS_REL_PATH = `${RUNTIME_ROOT}/artifacts`;
|
|
12
12
|
const STATE_DIR_REL_PATH = `${RUNTIME_ROOT}/state`;
|
|
@@ -161,150 +161,157 @@ export async function listRuns(projectRoot) {
|
|
|
161
161
|
}
|
|
162
162
|
export async function archiveRun(projectRoot, featureName, options = {}) {
|
|
163
163
|
await ensureRunSystem(projectRoot);
|
|
164
|
+
// Hold BOTH archive.lock and flow-state.lock for the entire archive:
|
|
165
|
+
// the outer archive lock serializes two concurrent archives; the
|
|
166
|
+
// inner flow-state lock prevents CLI / hook paths from mutating
|
|
167
|
+
// flow-state between the archive snapshot and the subsequent reset,
|
|
168
|
+
// which used to cause lost-update races.
|
|
164
169
|
return withDirectoryLock(archiveLockPath(projectRoot), async () => {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const feature = (featureName?.trim() && featureName.trim().length > 0)
|
|
171
|
-
? featureName.trim()
|
|
172
|
-
: await inferFeatureNameFromArtifacts(projectRoot);
|
|
173
|
-
const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
|
|
174
|
-
const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
|
|
175
|
-
const archivePath = path.join(runsDir, archiveId);
|
|
176
|
-
const archiveArtifactsPath = path.join(archivePath, "artifacts");
|
|
177
|
-
let sourceState = await readFlowState(projectRoot);
|
|
178
|
-
const retroGate = await evaluateRetroGate(projectRoot, sourceState);
|
|
179
|
-
const shipCompleted = sourceState.completedStages.includes("ship");
|
|
180
|
-
const skipRetro = options.skipRetro === true;
|
|
181
|
-
const skipRetroReason = options.skipRetroReason?.trim();
|
|
182
|
-
if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
|
|
183
|
-
throw new Error("archive --skip-retro requires --retro-reason=<text>.");
|
|
184
|
-
}
|
|
185
|
-
const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
|
|
186
|
-
typeof sourceState.closeout.retroSkipReason === "string" &&
|
|
187
|
-
sourceState.closeout.retroSkipReason.trim().length > 0;
|
|
188
|
-
const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
|
|
189
|
-
const inShipCloseout = sourceState.currentStage === "ship";
|
|
190
|
-
if (inShipCloseout && skipRetro) {
|
|
191
|
-
throw new Error("Archive blocked: --skip-retro is not allowed while current stage is ship. " +
|
|
192
|
-
"Complete closeout to ready_to_archive via /cc-next.");
|
|
193
|
-
}
|
|
194
|
-
if (inShipCloseout && !readyForArchive) {
|
|
195
|
-
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
196
|
-
"Resume /cc-next until closeout reaches ready_to_archive.");
|
|
197
|
-
}
|
|
198
|
-
if (shipCompleted && !readyForArchive && !skipRetro) {
|
|
199
|
-
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
200
|
-
"Resume /cc-next until closeout reaches ready_to_archive, " +
|
|
201
|
-
"or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
|
|
202
|
-
}
|
|
203
|
-
if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
|
|
204
|
-
throw new Error("Archive blocked: retro gate is required after ship completion. " +
|
|
205
|
-
"Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
|
|
206
|
-
}
|
|
207
|
-
if (retroGate.completed) {
|
|
208
|
-
const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
|
|
209
|
-
sourceState = {
|
|
210
|
-
...sourceState,
|
|
211
|
-
retro: {
|
|
212
|
-
required: retroGate.required,
|
|
213
|
-
completedAt,
|
|
214
|
-
compoundEntries: retroGate.compoundEntries
|
|
215
|
-
}
|
|
216
|
-
};
|
|
217
|
-
await writeFlowState(projectRoot, sourceState, { allowReset: true });
|
|
218
|
-
}
|
|
219
|
-
const retroSummary = {
|
|
220
|
-
required: retroGate.required,
|
|
221
|
-
completed: retroGate.completed,
|
|
222
|
-
skipped: skipRetro || retroSkippedInCloseout,
|
|
223
|
-
skipReason: skipRetro
|
|
224
|
-
? skipRetroReason
|
|
225
|
-
: retroSkippedInCloseout
|
|
226
|
-
? sourceState.closeout.retroSkipReason
|
|
227
|
-
: undefined,
|
|
228
|
-
compoundEntries: retroGate.compoundEntries
|
|
229
|
-
};
|
|
230
|
-
await ensureDir(archivePath);
|
|
231
|
-
// Drop an `.archive-in-progress` sentinel immediately so that a crash
|
|
232
|
-
// between the artifact rename and the final manifest write leaves a
|
|
233
|
-
// recoverable marker (doctor surfaces these; re-running archive on an
|
|
234
|
-
// orphan attempts to complete or roll back). The sentinel is removed
|
|
235
|
-
// only after the manifest lands successfully.
|
|
236
|
-
const sentinelPath = path.join(archivePath, ".archive-in-progress");
|
|
237
|
-
const archivedAt = new Date().toISOString();
|
|
238
|
-
await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
|
|
239
|
-
const stateBeforeReset = sourceState;
|
|
240
|
-
let artifactsMoved = false;
|
|
241
|
-
let stateReset = false;
|
|
242
|
-
try {
|
|
243
|
-
await fs.rename(artifactsDir, archiveArtifactsPath);
|
|
244
|
-
artifactsMoved = true;
|
|
170
|
+
return withDirectoryLock(flowStateLockPathFor(projectRoot), async () => {
|
|
171
|
+
const activeFeature = await readActiveFeature(projectRoot);
|
|
172
|
+
const artifactsDir = activeArtifactsPath(projectRoot);
|
|
173
|
+
const runsDir = runsRoot(projectRoot);
|
|
174
|
+
await ensureDir(runsDir);
|
|
245
175
|
await ensureDir(artifactsDir);
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
176
|
+
const feature = (featureName?.trim() && featureName.trim().length > 0)
|
|
177
|
+
? featureName.trim()
|
|
178
|
+
: await inferFeatureNameFromArtifacts(projectRoot);
|
|
179
|
+
const archiveBaseId = `${toArchiveDate()}-${slugifyFeatureName(feature)}`;
|
|
180
|
+
const archiveId = await uniqueArchiveId(projectRoot, archiveBaseId);
|
|
181
|
+
const archivePath = path.join(runsDir, archiveId);
|
|
182
|
+
const archiveArtifactsPath = path.join(archivePath, "artifacts");
|
|
183
|
+
let sourceState = await readFlowState(projectRoot);
|
|
184
|
+
const retroGate = await evaluateRetroGate(projectRoot, sourceState);
|
|
185
|
+
const shipCompleted = sourceState.completedStages.includes("ship");
|
|
186
|
+
const skipRetro = options.skipRetro === true;
|
|
187
|
+
const skipRetroReason = options.skipRetroReason?.trim();
|
|
188
|
+
if (skipRetro && (!skipRetroReason || skipRetroReason.length === 0)) {
|
|
189
|
+
throw new Error("archive --skip-retro requires --retro-reason=<text>.");
|
|
190
|
+
}
|
|
191
|
+
const retroSkippedInCloseout = sourceState.closeout.retroSkipped === true &&
|
|
192
|
+
typeof sourceState.closeout.retroSkipReason === "string" &&
|
|
193
|
+
sourceState.closeout.retroSkipReason.trim().length > 0;
|
|
194
|
+
const readyForArchive = sourceState.closeout.shipSubstate === "ready_to_archive";
|
|
195
|
+
const inShipCloseout = sourceState.currentStage === "ship";
|
|
196
|
+
if (inShipCloseout && skipRetro) {
|
|
197
|
+
throw new Error("Archive blocked: --skip-retro is not allowed while current stage is ship. " +
|
|
198
|
+
"Complete closeout to ready_to_archive via /cc-next.");
|
|
199
|
+
}
|
|
200
|
+
if (inShipCloseout && !readyForArchive) {
|
|
201
|
+
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
202
|
+
"Resume /cc-next until closeout reaches ready_to_archive.");
|
|
203
|
+
}
|
|
204
|
+
if (shipCompleted && !readyForArchive && !skipRetro) {
|
|
205
|
+
throw new Error("Archive blocked: closeout is not ready_to_archive. " +
|
|
206
|
+
"Resume /cc-next until closeout reaches ready_to_archive, " +
|
|
207
|
+
"or run `cclaw archive --skip-retro --retro-reason=<text>` for CLI-only flows.");
|
|
208
|
+
}
|
|
209
|
+
if (retroGate.required && !retroGate.completed && !skipRetro && !retroSkippedInCloseout) {
|
|
210
|
+
throw new Error("Archive blocked: retro gate is required after ship completion. " +
|
|
211
|
+
"Run /cc-next (auto-runs retro) or, for CLI-only flows, re-run `cclaw archive --skip-retro --retro-reason=<text>`.");
|
|
212
|
+
}
|
|
213
|
+
if (retroGate.completed) {
|
|
214
|
+
const completedAt = sourceState.retro.completedAt ?? new Date().toISOString();
|
|
215
|
+
sourceState = {
|
|
216
|
+
...sourceState,
|
|
217
|
+
retro: {
|
|
218
|
+
required: retroGate.required,
|
|
219
|
+
completedAt,
|
|
220
|
+
compoundEntries: retroGate.compoundEntries
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
await writeFlowState(projectRoot, sourceState, { allowReset: true, skipLock: true });
|
|
224
|
+
}
|
|
225
|
+
const retroSummary = {
|
|
226
|
+
required: retroGate.required,
|
|
227
|
+
completed: retroGate.completed,
|
|
228
|
+
skipped: skipRetro || retroSkippedInCloseout,
|
|
229
|
+
skipReason: skipRetro
|
|
230
|
+
? skipRetroReason
|
|
231
|
+
: retroSkippedInCloseout
|
|
232
|
+
? sourceState.closeout.retroSkipReason
|
|
233
|
+
: undefined,
|
|
234
|
+
compoundEntries: retroGate.compoundEntries
|
|
279
235
|
};
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
236
|
+
await ensureDir(archivePath);
|
|
237
|
+
// Drop an `.archive-in-progress` sentinel immediately so that a crash
|
|
238
|
+
// between the artifact rename and the final manifest write leaves a
|
|
239
|
+
// recoverable marker (doctor surfaces these; re-running archive on an
|
|
240
|
+
// orphan attempts to complete or roll back). The sentinel is removed
|
|
241
|
+
// only after the manifest lands successfully.
|
|
242
|
+
const sentinelPath = path.join(archivePath, ".archive-in-progress");
|
|
243
|
+
const archivedAt = new Date().toISOString();
|
|
244
|
+
await writeFileSafe(sentinelPath, `${JSON.stringify({ archiveId, startedAt: archivedAt, sourceRunId: sourceState.activeRunId }, null, 2)}\n`);
|
|
245
|
+
const stateBeforeReset = sourceState;
|
|
246
|
+
let artifactsMoved = false;
|
|
247
|
+
let stateReset = false;
|
|
248
|
+
try {
|
|
249
|
+
await fs.rename(artifactsDir, archiveArtifactsPath);
|
|
250
|
+
artifactsMoved = true;
|
|
251
|
+
await ensureDir(artifactsDir);
|
|
252
|
+
const archiveStatePath = path.join(archivePath, "state");
|
|
253
|
+
const snapshottedStateFiles = await snapshotStateDirectory(projectRoot, archiveStatePath);
|
|
254
|
+
const resetState = createInitialFlowState();
|
|
255
|
+
await writeFlowState(projectRoot, resetState, { allowReset: true, skipLock: true });
|
|
256
|
+
stateReset = true;
|
|
257
|
+
await resetCarryoverStateFiles(projectRoot, resetState.activeRunId);
|
|
258
|
+
const manifest = {
|
|
259
|
+
version: 1,
|
|
260
|
+
archiveId,
|
|
261
|
+
archivedAt,
|
|
262
|
+
featureName: feature,
|
|
263
|
+
activeFeature,
|
|
264
|
+
sourceRunId: sourceState.activeRunId,
|
|
265
|
+
sourceCurrentStage: sourceState.currentStage,
|
|
266
|
+
sourceCompletedStages: sourceState.completedStages,
|
|
267
|
+
snapshottedStateFiles,
|
|
268
|
+
retro: retroSummary
|
|
269
|
+
};
|
|
270
|
+
await writeFileSafe(path.join(archivePath, "archive-manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
271
|
+
// Manifest landed — sentinel is no longer needed.
|
|
272
|
+
await fs.unlink(sentinelPath).catch(() => undefined);
|
|
273
|
+
const knowledgeStats = await readKnowledgeStats(projectRoot);
|
|
274
|
+
await syncActiveFeatureSnapshot(projectRoot);
|
|
275
|
+
return {
|
|
276
|
+
archiveId,
|
|
277
|
+
archivePath,
|
|
278
|
+
archivedAt,
|
|
279
|
+
featureName: feature,
|
|
280
|
+
activeFeature,
|
|
281
|
+
resetState,
|
|
282
|
+
snapshottedStateFiles,
|
|
283
|
+
knowledge: knowledgeStats,
|
|
284
|
+
retro: retroSummary
|
|
285
|
+
};
|
|
295
286
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
287
|
+
catch (err) {
|
|
288
|
+
// Best-effort rollback: if artifacts were moved but the subsequent
|
|
289
|
+
// steps failed, put artifacts back so the user is not left without
|
|
290
|
+
// a working run. The sentinel is intentionally left behind for
|
|
291
|
+
// inspection; doctor surfaces it.
|
|
292
|
+
if (artifactsMoved) {
|
|
293
|
+
try {
|
|
294
|
+
await fs.rm(artifactsDir, { recursive: true, force: true });
|
|
295
|
+
await fs.rename(archiveArtifactsPath, artifactsDir);
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Rollback failed — sentinel + orphaned archive dir will be
|
|
299
|
+
// surfaced by doctor and can be reconciled manually.
|
|
300
|
+
}
|
|
300
301
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
if (stateReset) {
|
|
303
|
+
try {
|
|
304
|
+
await writeFlowState(projectRoot, stateBeforeReset, { allowReset: true, skipLock: true });
|
|
305
|
+
await resetCarryoverStateFiles(projectRoot, stateBeforeReset.activeRunId);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// If rollback of state fails, keep sentinel + archive remnants for
|
|
309
|
+
// manual reconciliation.
|
|
310
|
+
}
|
|
304
311
|
}
|
|
312
|
+
throw err;
|
|
305
313
|
}
|
|
306
|
-
|
|
307
|
-
}
|
|
314
|
+
});
|
|
308
315
|
}, {
|
|
309
316
|
retries: 400,
|
|
310
317
|
retryDelayMs: 25,
|