cclaw-cli 6.5.0 → 6.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/artifact-linter/brainstorm.js +2 -1
- package/dist/artifact-linter/design.js +2 -1
- package/dist/artifact-linter/findings-dedup.d.ts +56 -0
- package/dist/artifact-linter/findings-dedup.js +232 -0
- package/dist/artifact-linter/plan.js +4 -2
- package/dist/artifact-linter/review.js +2 -1
- package/dist/artifact-linter/scope.js +2 -1
- package/dist/artifact-linter/shared.d.ts +103 -0
- package/dist/artifact-linter/shared.js +177 -0
- package/dist/artifact-linter/tdd.js +2 -1
- package/dist/artifact-linter.d.ts +1 -1
- package/dist/artifact-linter.js +45 -3
- package/dist/content/examples.d.ts +32 -0
- package/dist/content/examples.js +74 -0
- package/dist/content/hooks.js +36 -1
- package/dist/content/node-hooks.js +43 -0
- package/dist/content/skills-elicitation.js +3 -6
- package/dist/content/skills.d.ts +10 -0
- package/dist/content/skills.js +44 -2
- package/dist/content/stages/brainstorm.js +7 -5
- package/dist/content/stages/design.js +3 -1
- package/dist/content/stages/plan.js +3 -1
- package/dist/content/stages/review.js +3 -1
- package/dist/content/stages/scope.js +5 -3
- package/dist/content/stages/ship.js +2 -1
- package/dist/content/stages/spec.js +3 -1
- package/dist/content/stages/tdd.js +3 -1
- package/dist/content/templates.d.ts +9 -0
- package/dist/content/templates.js +45 -2
- package/dist/delegation.d.ts +9 -0
- package/dist/delegation.js +3 -0
- package/dist/internal/advance-stage/advance.js +23 -1
- package/dist/internal/advance-stage/parsers.d.ts +8 -0
- package/dist/internal/advance-stage/parsers.js +7 -0
- package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +3 -0
- package/dist/internal/advance-stage/proactive-delegation-trace.js +8 -1
- package/dist/internal/advance-stage/rewind.js +2 -2
- package/dist/internal/advance-stage/start-flow.js +4 -1
- package/dist/internal/advance-stage.js +32 -2
- package/dist/internal/flow-state-repair.d.ts +13 -0
- package/dist/internal/flow-state-repair.js +65 -0
- package/dist/internal/waiver-grant.d.ts +62 -0
- package/dist/internal/waiver-grant.js +294 -0
- package/dist/run-persistence.d.ts +70 -0
- package/dist/run-persistence.js +215 -3
- package/dist/runs.d.ts +1 -1
- package/dist/runs.js +1 -1
- package/dist/runtime/run-hook.mjs +43 -0
- package/package.json +1 -1
|
@@ -11,13 +11,33 @@ import { runRewind } from "./advance-stage/rewind.js";
|
|
|
11
11
|
import { runVerifyFlowStateDiff, runVerifyCurrentState } from "./advance-stage/verify.js";
|
|
12
12
|
import { runHookCommand } from "./advance-stage/hook.js";
|
|
13
13
|
import { parseAdvanceStageArgs, parseCancelRunArgs, parseHookArgs, parseRewindArgs, parseStartFlowArgs, parseVerifyCurrentStateArgs, parseVerifyFlowStateDiffArgs } from "./advance-stage/parsers.js";
|
|
14
|
+
import { parseFlowStateRepairArgs, runFlowStateRepair } from "./flow-state-repair.js";
|
|
15
|
+
import { parseWaiverGrantArgs, runWaiverGrant } from "./waiver-grant.js";
|
|
16
|
+
import { FlowStateGuardMismatchError, verifyFlowStateGuard } from "../run-persistence.js";
|
|
17
|
+
/**
|
|
18
|
+
* Subcommands that mutate or consult flow-state.json via the CLI runtime.
|
|
19
|
+
* They all require the sha256 sidecar to match before continuing so a
|
|
20
|
+
* manual edit hard-blocks with exit code 2 (same contract as the inline
|
|
21
|
+
* hook checks).
|
|
22
|
+
*/
|
|
23
|
+
const GUARD_ENFORCED_SUBCOMMANDS = new Set([
|
|
24
|
+
"advance-stage",
|
|
25
|
+
"start-flow",
|
|
26
|
+
"cancel-run",
|
|
27
|
+
"rewind",
|
|
28
|
+
"verify-flow-state-diff",
|
|
29
|
+
"verify-current-state"
|
|
30
|
+
]);
|
|
14
31
|
export async function runInternalCommand(projectRoot, argv, io) {
|
|
15
32
|
const [subcommand, ...tokens] = argv;
|
|
16
33
|
if (!subcommand) {
|
|
17
|
-
io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook\n");
|
|
34
|
+
io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant\n");
|
|
18
35
|
return 1;
|
|
19
36
|
}
|
|
20
37
|
try {
|
|
38
|
+
if (GUARD_ENFORCED_SUBCOMMANDS.has(subcommand)) {
|
|
39
|
+
await verifyFlowStateGuard(projectRoot);
|
|
40
|
+
}
|
|
21
41
|
if (subcommand === "advance-stage") {
|
|
22
42
|
return await runAdvanceStage(projectRoot, parseAdvanceStageArgs(tokens), io);
|
|
23
43
|
}
|
|
@@ -57,10 +77,20 @@ export async function runInternalCommand(projectRoot, argv, io) {
|
|
|
57
77
|
if (subcommand === "hook") {
|
|
58
78
|
return await runHookCommand(projectRoot, parseHookArgs(tokens), io);
|
|
59
79
|
}
|
|
60
|
-
|
|
80
|
+
if (subcommand === "flow-state-repair") {
|
|
81
|
+
return await runFlowStateRepair(projectRoot, parseFlowStateRepairArgs(tokens), io);
|
|
82
|
+
}
|
|
83
|
+
if (subcommand === "waiver-grant") {
|
|
84
|
+
return await runWaiverGrant(projectRoot, parseWaiverGrantArgs(tokens), io);
|
|
85
|
+
}
|
|
86
|
+
io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant\n`);
|
|
61
87
|
return 1;
|
|
62
88
|
}
|
|
63
89
|
catch (err) {
|
|
90
|
+
if (err instanceof FlowStateGuardMismatchError) {
|
|
91
|
+
io.stderr.write(`cclaw internal ${subcommand}: ${err.message}\n`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
64
94
|
io.stderr.write(`cclaw internal ${subcommand} failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
65
95
|
return 1;
|
|
66
96
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Writable } from "node:stream";
|
|
2
|
+
interface InternalIo {
|
|
3
|
+
stdout: Writable;
|
|
4
|
+
stderr: Writable;
|
|
5
|
+
}
|
|
6
|
+
export interface FlowStateRepairArgs {
|
|
7
|
+
reason: string;
|
|
8
|
+
json: boolean;
|
|
9
|
+
quiet: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function parseFlowStateRepairArgs(tokens: string[]): FlowStateRepairArgs;
|
|
12
|
+
export declare function runFlowStateRepair(projectRoot: string, args: FlowStateRepairArgs, io: InternalIo): Promise<number>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { RUNTIME_ROOT } from "../constants.js";
|
|
3
|
+
import { repairFlowStateGuard } from "../run-persistence.js";
|
|
4
|
+
export function parseFlowStateRepairArgs(tokens) {
|
|
5
|
+
let reason;
|
|
6
|
+
let json = false;
|
|
7
|
+
let quiet = false;
|
|
8
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
9
|
+
const token = tokens[i];
|
|
10
|
+
const nextToken = tokens[i + 1];
|
|
11
|
+
if (token === "--json") {
|
|
12
|
+
json = true;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (token === "--quiet") {
|
|
16
|
+
quiet = true;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (token === "--reason") {
|
|
20
|
+
if (!nextToken || nextToken.startsWith("--")) {
|
|
21
|
+
throw new Error("--reason requires a short slug value.");
|
|
22
|
+
}
|
|
23
|
+
reason = nextToken.trim();
|
|
24
|
+
i += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (token.startsWith("--reason=")) {
|
|
28
|
+
reason = token.slice("--reason=".length).trim();
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Unknown flag for internal flow-state-repair: ${token}`);
|
|
32
|
+
}
|
|
33
|
+
if (!reason || reason.length === 0) {
|
|
34
|
+
throw new Error("internal flow-state-repair requires --reason=<slug> (e.g. --reason=manual_edit_recovery).");
|
|
35
|
+
}
|
|
36
|
+
return { reason, json, quiet };
|
|
37
|
+
}
|
|
38
|
+
export async function runFlowStateRepair(projectRoot, args, io) {
|
|
39
|
+
const result = await repairFlowStateGuard(projectRoot, args.reason);
|
|
40
|
+
const logRel = path.relative(projectRoot, result.repairLogPath).replace(/\\/gu, "/");
|
|
41
|
+
const guardRel = path.relative(projectRoot, result.guardPath).replace(/\\/gu, "/");
|
|
42
|
+
if (args.json) {
|
|
43
|
+
io.stdout.write(`${JSON.stringify({
|
|
44
|
+
ok: true,
|
|
45
|
+
command: "flow-state-repair",
|
|
46
|
+
reason: args.reason,
|
|
47
|
+
sidecar: result.sidecar,
|
|
48
|
+
guardPath: guardRel,
|
|
49
|
+
repairLogPath: logRel,
|
|
50
|
+
runtimeRoot: RUNTIME_ROOT
|
|
51
|
+
})}\n`);
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
if (!args.quiet) {
|
|
55
|
+
io.stdout.write(`${JSON.stringify({
|
|
56
|
+
ok: true,
|
|
57
|
+
command: "flow-state-repair",
|
|
58
|
+
reason: args.reason,
|
|
59
|
+
sidecar: result.sidecar,
|
|
60
|
+
guardPath: guardRel,
|
|
61
|
+
repairLogPath: logRel
|
|
62
|
+
}, null, 2)}\n`);
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type FlowStage } from "../types.js";
|
|
2
|
+
import type { Writable } from "node:stream";
|
|
3
|
+
interface InternalIo {
|
|
4
|
+
stdout: Writable;
|
|
5
|
+
stderr: Writable;
|
|
6
|
+
}
|
|
7
|
+
export declare const WAIVER_TOKEN_DEFAULT_TTL_MINUTES = 30;
|
|
8
|
+
export declare const WAIVER_TOKEN_MAX_TTL_MINUTES = 120;
|
|
9
|
+
export declare const WAIVER_REASON_PATTERN: RegExp;
|
|
10
|
+
export interface WaiverRecord {
|
|
11
|
+
token: string;
|
|
12
|
+
stage: FlowStage;
|
|
13
|
+
reason: string;
|
|
14
|
+
issuedAt: string;
|
|
15
|
+
expiresAt: string;
|
|
16
|
+
consumedAt: string | null;
|
|
17
|
+
issuerSubsystem: string;
|
|
18
|
+
consumedBy?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface WaiverLedger {
|
|
21
|
+
schemaVersion: number;
|
|
22
|
+
pending: WaiverRecord[];
|
|
23
|
+
consumed: WaiverRecord[];
|
|
24
|
+
}
|
|
25
|
+
export interface IssueWaiverTokenOptions {
|
|
26
|
+
stage: FlowStage;
|
|
27
|
+
reason: string;
|
|
28
|
+
expiresInMinutes?: number;
|
|
29
|
+
issuerSubsystem?: string;
|
|
30
|
+
now?: Date;
|
|
31
|
+
}
|
|
32
|
+
export interface ConsumeWaiverOptions {
|
|
33
|
+
stage: FlowStage;
|
|
34
|
+
token: string;
|
|
35
|
+
consumedBy?: string;
|
|
36
|
+
now?: Date;
|
|
37
|
+
}
|
|
38
|
+
export declare function formatWaiverToken(stage: FlowStage, fingerprint: string, expiresAt: Date): string;
|
|
39
|
+
export declare function issueWaiverToken(projectRoot: string, options: IssueWaiverTokenOptions): Promise<WaiverRecord>;
|
|
40
|
+
export type ConsumeWaiverFailureReason = "not-found" | "wrong-stage" | "expired" | "already-consumed";
|
|
41
|
+
export interface ConsumeWaiverSuccess {
|
|
42
|
+
ok: true;
|
|
43
|
+
record: WaiverRecord;
|
|
44
|
+
}
|
|
45
|
+
export interface ConsumeWaiverFailure {
|
|
46
|
+
ok: false;
|
|
47
|
+
reason: ConsumeWaiverFailureReason;
|
|
48
|
+
record?: WaiverRecord;
|
|
49
|
+
detail: string;
|
|
50
|
+
}
|
|
51
|
+
export type ConsumeWaiverResult = ConsumeWaiverSuccess | ConsumeWaiverFailure;
|
|
52
|
+
export declare function consumeWaiverToken(projectRoot: string, options: ConsumeWaiverOptions): Promise<ConsumeWaiverResult>;
|
|
53
|
+
export interface WaiverGrantArgs {
|
|
54
|
+
stage: FlowStage;
|
|
55
|
+
reason: string;
|
|
56
|
+
ttlMinutes: number;
|
|
57
|
+
json: boolean;
|
|
58
|
+
quiet: boolean;
|
|
59
|
+
}
|
|
60
|
+
export declare function parseWaiverGrantArgs(tokens: string[]): WaiverGrantArgs;
|
|
61
|
+
export declare function runWaiverGrant(projectRoot: string, args: WaiverGrantArgs, io: InternalIo): Promise<number>;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { RUNTIME_ROOT } from "../constants.js";
|
|
5
|
+
import { ensureDir, exists, withDirectoryLock, writeFileSafe } from "../fs-utils.js";
|
|
6
|
+
import { FLOW_STAGES } from "../types.js";
|
|
7
|
+
/**
|
|
8
|
+
* Tokens issued by `cclaw internal waiver-grant` live under the runtime
|
|
9
|
+
* root. The ledger also tracks `consumed[]` entries so consumption is
|
|
10
|
+
* traceable and one-shot.
|
|
11
|
+
*/
|
|
12
|
+
const WAIVER_LEDGER_REL_PATH = `${RUNTIME_ROOT}/.waivers.json`;
|
|
13
|
+
const WAIVER_LEDGER_LOCK_REL_PATH = `${RUNTIME_ROOT}/.waivers.json.lock`;
|
|
14
|
+
export const WAIVER_TOKEN_DEFAULT_TTL_MINUTES = 30;
|
|
15
|
+
export const WAIVER_TOKEN_MAX_TTL_MINUTES = 120;
|
|
16
|
+
export const WAIVER_REASON_PATTERN = /^[a-z][a-z0-9_-]{2,}$/u;
|
|
17
|
+
const WAIVER_TOKEN_PREFIX = "WV";
|
|
18
|
+
const WAIVER_LEDGER_SCHEMA_VERSION = 1;
|
|
19
|
+
function waiverLedgerPath(projectRoot) {
|
|
20
|
+
return path.join(projectRoot, WAIVER_LEDGER_REL_PATH);
|
|
21
|
+
}
|
|
22
|
+
function waiverLedgerLockPath(projectRoot) {
|
|
23
|
+
return path.join(projectRoot, WAIVER_LEDGER_LOCK_REL_PATH);
|
|
24
|
+
}
|
|
25
|
+
function sanitizeWaiverRecord(value) {
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
27
|
+
return null;
|
|
28
|
+
const row = value;
|
|
29
|
+
const token = typeof row.token === "string" ? row.token : "";
|
|
30
|
+
const stage = row.stage;
|
|
31
|
+
const reason = typeof row.reason === "string" ? row.reason : "";
|
|
32
|
+
const issuedAt = typeof row.issuedAt === "string" ? row.issuedAt : "";
|
|
33
|
+
const expiresAt = typeof row.expiresAt === "string" ? row.expiresAt : "";
|
|
34
|
+
const consumedAt = typeof row.consumedAt === "string" ? row.consumedAt : null;
|
|
35
|
+
const issuerSubsystem = typeof row.issuerSubsystem === "string" ? row.issuerSubsystem : "";
|
|
36
|
+
const consumedBy = typeof row.consumedBy === "string" ? row.consumedBy : undefined;
|
|
37
|
+
if (token.length === 0 ||
|
|
38
|
+
typeof stage !== "string" ||
|
|
39
|
+
!FLOW_STAGES.includes(stage) ||
|
|
40
|
+
reason.length === 0 ||
|
|
41
|
+
issuedAt.length === 0 ||
|
|
42
|
+
expiresAt.length === 0 ||
|
|
43
|
+
issuerSubsystem.length === 0) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
token,
|
|
48
|
+
stage: stage,
|
|
49
|
+
reason,
|
|
50
|
+
issuedAt,
|
|
51
|
+
expiresAt,
|
|
52
|
+
consumedAt,
|
|
53
|
+
issuerSubsystem,
|
|
54
|
+
...(consumedBy ? { consumedBy } : {})
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function readWaiverLedger(projectRoot) {
|
|
58
|
+
const statePath = waiverLedgerPath(projectRoot);
|
|
59
|
+
if (!(await exists(statePath))) {
|
|
60
|
+
return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
|
|
61
|
+
}
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = await fs.readFile(statePath, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
|
|
68
|
+
}
|
|
69
|
+
let parsed;
|
|
70
|
+
try {
|
|
71
|
+
parsed = JSON.parse(raw);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
|
|
75
|
+
}
|
|
76
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
77
|
+
return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending: [], consumed: [] };
|
|
78
|
+
}
|
|
79
|
+
const typed = parsed;
|
|
80
|
+
const pending = Array.isArray(typed.pending)
|
|
81
|
+
? typed.pending
|
|
82
|
+
.map((item) => sanitizeWaiverRecord(item))
|
|
83
|
+
.filter((item) => item !== null)
|
|
84
|
+
: [];
|
|
85
|
+
const consumed = Array.isArray(typed.consumed)
|
|
86
|
+
? typed.consumed
|
|
87
|
+
.map((item) => sanitizeWaiverRecord(item))
|
|
88
|
+
.filter((item) => item !== null)
|
|
89
|
+
: [];
|
|
90
|
+
return { schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION, pending, consumed };
|
|
91
|
+
}
|
|
92
|
+
async function writeWaiverLedger(projectRoot, ledger) {
|
|
93
|
+
const next = {
|
|
94
|
+
schemaVersion: WAIVER_LEDGER_SCHEMA_VERSION,
|
|
95
|
+
pending: ledger.pending,
|
|
96
|
+
consumed: ledger.consumed
|
|
97
|
+
};
|
|
98
|
+
await writeFileSafe(waiverLedgerPath(projectRoot), `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
99
|
+
}
|
|
100
|
+
function formatExpiresSlug(expiresAt) {
|
|
101
|
+
// Minute-precision slug for the token: e.g. `20260502T220500Z`
|
|
102
|
+
return expiresAt.toISOString().replace(/[-:]/gu, "").replace(/\..+$/u, "").concat("Z");
|
|
103
|
+
}
|
|
104
|
+
function minuteFingerprint(stage, reason, issuedAt) {
|
|
105
|
+
const payload = `${stage}|${reason}|${issuedAt.toISOString()}|${Math.random().toString(16).slice(2, 12)}`;
|
|
106
|
+
return createHash("sha256").update(payload, "utf8").digest("hex").slice(0, 8);
|
|
107
|
+
}
|
|
108
|
+
export function formatWaiverToken(stage, fingerprint, expiresAt) {
|
|
109
|
+
return `${WAIVER_TOKEN_PREFIX}-${stage}-${fingerprint}-${formatExpiresSlug(expiresAt)}`;
|
|
110
|
+
}
|
|
111
|
+
export async function issueWaiverToken(projectRoot, options) {
|
|
112
|
+
if (!FLOW_STAGES.includes(options.stage)) {
|
|
113
|
+
throw new Error(`waiver-grant: --stage must be one of ${FLOW_STAGES.join(", ")}.`);
|
|
114
|
+
}
|
|
115
|
+
const reason = options.reason.trim();
|
|
116
|
+
if (reason.length === 0) {
|
|
117
|
+
throw new Error("waiver-grant: --reason is required.");
|
|
118
|
+
}
|
|
119
|
+
if (!WAIVER_REASON_PATTERN.test(reason)) {
|
|
120
|
+
throw new Error("waiver-grant: --reason must match /^[a-z][a-z0-9_-]{2,}$/ (short lowercase slug, e.g. architect_unavailable).");
|
|
121
|
+
}
|
|
122
|
+
const ttlRaw = typeof options.expiresInMinutes === "number" && Number.isFinite(options.expiresInMinutes)
|
|
123
|
+
? Math.floor(options.expiresInMinutes)
|
|
124
|
+
: WAIVER_TOKEN_DEFAULT_TTL_MINUTES;
|
|
125
|
+
if (ttlRaw < 1) {
|
|
126
|
+
throw new Error("waiver-grant: --ttl must be >= 1 minute.");
|
|
127
|
+
}
|
|
128
|
+
if (ttlRaw > WAIVER_TOKEN_MAX_TTL_MINUTES) {
|
|
129
|
+
throw new Error(`waiver-grant: --ttl must be <= ${WAIVER_TOKEN_MAX_TTL_MINUTES} minutes.`);
|
|
130
|
+
}
|
|
131
|
+
const issuedAtDate = options.now ?? new Date();
|
|
132
|
+
const expiresAtDate = new Date(issuedAtDate.getTime() + ttlRaw * 60 * 1000);
|
|
133
|
+
const fingerprint = minuteFingerprint(options.stage, reason, issuedAtDate);
|
|
134
|
+
const token = formatWaiverToken(options.stage, fingerprint, expiresAtDate);
|
|
135
|
+
const record = {
|
|
136
|
+
token,
|
|
137
|
+
stage: options.stage,
|
|
138
|
+
reason,
|
|
139
|
+
issuedAt: issuedAtDate.toISOString(),
|
|
140
|
+
expiresAt: expiresAtDate.toISOString(),
|
|
141
|
+
consumedAt: null,
|
|
142
|
+
issuerSubsystem: options.issuerSubsystem?.trim() || "cli"
|
|
143
|
+
};
|
|
144
|
+
await ensureDir(path.dirname(waiverLedgerPath(projectRoot)));
|
|
145
|
+
await withDirectoryLock(waiverLedgerLockPath(projectRoot), async () => {
|
|
146
|
+
const ledger = await readWaiverLedger(projectRoot);
|
|
147
|
+
ledger.pending.push(record);
|
|
148
|
+
await writeWaiverLedger(projectRoot, ledger);
|
|
149
|
+
});
|
|
150
|
+
return record;
|
|
151
|
+
}
|
|
152
|
+
export async function consumeWaiverToken(projectRoot, options) {
|
|
153
|
+
const token = options.token.trim();
|
|
154
|
+
if (token.length === 0) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
reason: "not-found",
|
|
158
|
+
detail: "waiver token is required"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const now = (options.now ?? new Date()).getTime();
|
|
162
|
+
return withDirectoryLock(waiverLedgerLockPath(projectRoot), async () => {
|
|
163
|
+
const ledger = await readWaiverLedger(projectRoot);
|
|
164
|
+
const pendingIdx = ledger.pending.findIndex((entry) => entry.token === token);
|
|
165
|
+
const consumedMatch = ledger.consumed.find((entry) => entry.token === token);
|
|
166
|
+
if (pendingIdx < 0) {
|
|
167
|
+
if (consumedMatch) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
reason: "already-consumed",
|
|
171
|
+
record: consumedMatch,
|
|
172
|
+
detail: `waiver token ${token} was already consumed at ${consumedMatch.consumedAt ?? "unknown time"}`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: "not-found",
|
|
178
|
+
detail: `no pending waiver token "${token}" found in ${WAIVER_LEDGER_REL_PATH}`
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const record = ledger.pending[pendingIdx];
|
|
182
|
+
if (record.stage !== options.stage) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
reason: "wrong-stage",
|
|
186
|
+
record,
|
|
187
|
+
detail: `waiver token ${token} was issued for stage "${record.stage}", not "${options.stage}"`
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const expiresAt = Date.parse(record.expiresAt);
|
|
191
|
+
if (Number.isFinite(expiresAt) && expiresAt < now) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
reason: "expired",
|
|
195
|
+
record,
|
|
196
|
+
detail: `waiver token ${token} expired at ${record.expiresAt}`
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const consumedAtIso = (options.now ?? new Date()).toISOString();
|
|
200
|
+
const consumedRecord = {
|
|
201
|
+
...record,
|
|
202
|
+
consumedAt: consumedAtIso,
|
|
203
|
+
...(options.consumedBy ? { consumedBy: options.consumedBy } : {})
|
|
204
|
+
};
|
|
205
|
+
ledger.pending.splice(pendingIdx, 1);
|
|
206
|
+
ledger.consumed.push(consumedRecord);
|
|
207
|
+
await writeWaiverLedger(projectRoot, ledger);
|
|
208
|
+
return { ok: true, record: consumedRecord };
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
export function parseWaiverGrantArgs(tokens) {
|
|
212
|
+
let stage;
|
|
213
|
+
let reason;
|
|
214
|
+
let ttlMinutes = WAIVER_TOKEN_DEFAULT_TTL_MINUTES;
|
|
215
|
+
let json = false;
|
|
216
|
+
let quiet = false;
|
|
217
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
218
|
+
const token = tokens[i];
|
|
219
|
+
const nextToken = tokens[i + 1];
|
|
220
|
+
const readValue = (flag) => {
|
|
221
|
+
if (token.startsWith(`${flag}=`))
|
|
222
|
+
return token.slice(flag.length + 1);
|
|
223
|
+
if (token === flag && nextToken && !nextToken.startsWith("--")) {
|
|
224
|
+
i += 1;
|
|
225
|
+
return nextToken;
|
|
226
|
+
}
|
|
227
|
+
throw new Error(`${flag} requires a value.`);
|
|
228
|
+
};
|
|
229
|
+
if (token === "--json") {
|
|
230
|
+
json = true;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (token === "--quiet") {
|
|
234
|
+
quiet = true;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (token === "--stage" || token.startsWith("--stage=")) {
|
|
238
|
+
const raw = readValue("--stage").trim();
|
|
239
|
+
if (!FLOW_STAGES.includes(raw)) {
|
|
240
|
+
throw new Error(`waiver-grant: --stage must be one of ${FLOW_STAGES.join(", ")}.`);
|
|
241
|
+
}
|
|
242
|
+
stage = raw;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (token === "--reason" || token.startsWith("--reason=")) {
|
|
246
|
+
reason = readValue("--reason").trim();
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (token === "--ttl" || token.startsWith("--ttl=")) {
|
|
250
|
+
const raw = readValue("--ttl").trim();
|
|
251
|
+
if (!/^[0-9]+$/u.test(raw)) {
|
|
252
|
+
throw new Error("waiver-grant: --ttl must be an integer number of minutes.");
|
|
253
|
+
}
|
|
254
|
+
ttlMinutes = Number(raw);
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
throw new Error(`Unknown flag for internal waiver-grant: ${token}`);
|
|
258
|
+
}
|
|
259
|
+
if (!stage) {
|
|
260
|
+
throw new Error(`internal waiver-grant requires --stage=<${FLOW_STAGES.join("|")}>.`);
|
|
261
|
+
}
|
|
262
|
+
if (!reason) {
|
|
263
|
+
throw new Error(`internal waiver-grant requires --reason=<short-slug> (e.g. architect_unavailable).`);
|
|
264
|
+
}
|
|
265
|
+
return { stage, reason, ttlMinutes, json, quiet };
|
|
266
|
+
}
|
|
267
|
+
export async function runWaiverGrant(projectRoot, args, io) {
|
|
268
|
+
const record = await issueWaiverToken(projectRoot, {
|
|
269
|
+
stage: args.stage,
|
|
270
|
+
reason: args.reason,
|
|
271
|
+
expiresInMinutes: args.ttlMinutes,
|
|
272
|
+
issuerSubsystem: "cli"
|
|
273
|
+
});
|
|
274
|
+
if (args.json) {
|
|
275
|
+
io.stdout.write(`${JSON.stringify({
|
|
276
|
+
ok: true,
|
|
277
|
+
command: "waiver-grant",
|
|
278
|
+
token: record.token,
|
|
279
|
+
stage: record.stage,
|
|
280
|
+
reason: record.reason,
|
|
281
|
+
issuedAt: record.issuedAt,
|
|
282
|
+
expiresAt: record.expiresAt,
|
|
283
|
+
ttlMinutes: args.ttlMinutes,
|
|
284
|
+
consumption: `cclaw-cli internal advance-stage ${record.stage} --accept-proactive-waiver=${record.token} --accept-proactive-waiver-reason="${record.reason}"`
|
|
285
|
+
})}\n`);
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
io.stdout.write(`${record.token}\n`);
|
|
289
|
+
if (!args.quiet) {
|
|
290
|
+
io.stdout.write(`Waiver token issued for stage="${record.stage}" reason="${record.reason}" expires=${record.expiresAt}.\n`);
|
|
291
|
+
io.stdout.write(`Consume with: node ${RUNTIME_ROOT}/hooks/stage-complete.mjs ${record.stage} --accept-proactive-waiver=${record.token} --accept-proactive-waiver-reason="${record.reason}"\n`);
|
|
292
|
+
}
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
@@ -18,6 +18,13 @@ export interface WriteFlowStateOptions {
|
|
|
18
18
|
* flow-state reset inside one atomic lock window.
|
|
19
19
|
*/
|
|
20
20
|
skipLock?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Free-form writer identifier persisted in the `.flow-state.guard.json`
|
|
23
|
+
* sidecar. Helps operators trace which subsystem wrote a given state
|
|
24
|
+
* (e.g. `advance-stage`, `start-flow`, `run-archive`). Defaults to
|
|
25
|
+
* `cclaw-cli` when omitted.
|
|
26
|
+
*/
|
|
27
|
+
writerSubsystem?: string;
|
|
21
28
|
}
|
|
22
29
|
export interface ReadFlowStateOptions {
|
|
23
30
|
/**
|
|
@@ -26,13 +33,76 @@ export interface ReadFlowStateOptions {
|
|
|
26
33
|
*/
|
|
27
34
|
repairFeatureSystem?: boolean;
|
|
28
35
|
}
|
|
36
|
+
export interface FlowStateGuardSidecar {
|
|
37
|
+
sha256: string;
|
|
38
|
+
writtenAt: string;
|
|
39
|
+
writerSubsystem: string;
|
|
40
|
+
runId: string;
|
|
41
|
+
}
|
|
42
|
+
export interface FlowStateGuardMismatchDetails {
|
|
43
|
+
expectedSha: string;
|
|
44
|
+
actualSha: string;
|
|
45
|
+
lastWriter: string;
|
|
46
|
+
writtenAt: string;
|
|
47
|
+
runId: string;
|
|
48
|
+
statePath: string;
|
|
49
|
+
guardPath: string;
|
|
50
|
+
repairCommand: string;
|
|
51
|
+
}
|
|
52
|
+
export declare class FlowStateGuardMismatchError extends Error {
|
|
53
|
+
readonly expectedSha: string;
|
|
54
|
+
readonly actualSha: string;
|
|
55
|
+
readonly lastWriter: string;
|
|
56
|
+
readonly writtenAt: string;
|
|
57
|
+
readonly runId: string;
|
|
58
|
+
readonly statePath: string;
|
|
59
|
+
readonly guardPath: string;
|
|
60
|
+
readonly repairCommand: string;
|
|
61
|
+
constructor(details: FlowStateGuardMismatchDetails);
|
|
62
|
+
}
|
|
29
63
|
export declare class CorruptFlowStateError extends Error {
|
|
30
64
|
readonly statePath: string;
|
|
31
65
|
readonly quarantinedPath: string;
|
|
32
66
|
constructor(statePath: string, quarantinedPath: string, cause: unknown);
|
|
33
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Verify the on-disk flow-state against the sha256 sidecar. Throws
|
|
70
|
+
* `FlowStateGuardMismatchError` when manual editing is detected. Safe to
|
|
71
|
+
* call on projects that have never written a sidecar: a missing sidecar is
|
|
72
|
+
* treated as "legacy runtime" and the check silently succeeds.
|
|
73
|
+
*/
|
|
74
|
+
export declare function verifyFlowStateGuard(projectRoot: string): Promise<void>;
|
|
34
75
|
export declare function readFlowState(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
|
|
76
|
+
/**
|
|
77
|
+
* Guarded read wrapper used by runtime hook scripts and the repair CLI.
|
|
78
|
+
* Unlike `readFlowState`, it enforces the sha256 sidecar before returning:
|
|
79
|
+
* a manual edit to flow-state.json fails fast with
|
|
80
|
+
* `FlowStateGuardMismatchError`.
|
|
81
|
+
*/
|
|
82
|
+
export declare function readFlowStateGuarded(projectRoot: string, options?: ReadFlowStateOptions): Promise<FlowState>;
|
|
35
83
|
export declare function writeFlowState(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
|
|
84
|
+
/**
|
|
85
|
+
* Named entry point for the write-guard workstream. Equivalent to
|
|
86
|
+
* `writeFlowState`: the write always produces the sha256 sidecar via
|
|
87
|
+
* the internal implementation so every existing writer inherits the
|
|
88
|
+
* guard without rewriting callsites.
|
|
89
|
+
*/
|
|
90
|
+
export declare function writeFlowStateGuarded(projectRoot: string, state: FlowState, options?: WriteFlowStateOptions): Promise<void>;
|
|
91
|
+
export interface FlowStateRepairResult {
|
|
92
|
+
sidecar: FlowStateGuardSidecar;
|
|
93
|
+
repairLogPath: string;
|
|
94
|
+
guardPath: string;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Recompute the write-guard sidecar from the current on-disk flow-state
|
|
98
|
+
* contents and append an audit entry to `.cclaw/.flow-state-repair.log`.
|
|
99
|
+
* The reason is required so no repair happens without an operator-visible
|
|
100
|
+
* rationale. Intended to be called only from the explicit
|
|
101
|
+
* `cclaw-cli internal flow-state-repair` subcommand.
|
|
102
|
+
*/
|
|
103
|
+
export declare function repairFlowStateGuard(projectRoot: string, reason: string): Promise<FlowStateRepairResult>;
|
|
104
|
+
export declare function flowStateGuardSidecarPathFor(projectRoot: string): string;
|
|
105
|
+
export declare function flowStateRepairLogPathFor(projectRoot: string): string;
|
|
36
106
|
/**
|
|
37
107
|
* Exposed path helper so callers that need to serialize a multi-step
|
|
38
108
|
* state operation with flow-state writes (e.g. run archival) can
|