cclaw-cli 0.12.0 → 0.13.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/cli.d.ts +2 -0
- package/dist/cli.js +14 -1
- package/dist/config.js +19 -0
- package/dist/constants.d.ts +2 -2
- package/dist/constants.js +13 -1
- package/dist/content/diff-command.d.ts +2 -0
- package/dist/content/diff-command.js +83 -0
- package/dist/content/feature-command.d.ts +2 -0
- package/dist/content/feature-command.js +120 -0
- package/dist/content/harnesses-doc.js +8 -0
- package/dist/content/hooks.js +47 -1
- package/dist/content/meta-skill.js +3 -2
- package/dist/content/next-command.js +8 -6
- package/dist/content/observe.d.ts +5 -1
- package/dist/content/observe.js +134 -2
- package/dist/content/retro-command.d.ts +2 -0
- package/dist/content/retro-command.js +77 -0
- package/dist/content/rewind-command.d.ts +3 -0
- package/dist/content/rewind-command.js +120 -0
- package/dist/content/status-command.js +43 -35
- package/dist/content/tdd-log-command.d.ts +2 -0
- package/dist/content/tdd-log-command.js +75 -0
- package/dist/content/templates.js +35 -5
- package/dist/content/tree-command.d.ts +2 -0
- package/dist/content/tree-command.js +91 -0
- package/dist/doctor.js +149 -3
- package/dist/feature-system.d.ts +18 -0
- package/dist/feature-system.js +247 -0
- package/dist/flow-state.d.ts +25 -0
- package/dist/flow-state.js +8 -1
- package/dist/harness-adapters.js +74 -4
- package/dist/install.js +35 -2
- package/dist/policy.js +22 -0
- package/dist/runs.d.ts +33 -1
- package/dist/runs.js +365 -6
- package/dist/tdd-cycle.d.ts +22 -0
- package/dist/tdd-cycle.js +82 -0
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { RUNTIME_ROOT } from "../constants.js";
|
|
2
|
+
const TREE_SKILL_FOLDER = "flow-tree";
|
|
3
|
+
const TREE_SKILL_NAME = "flow-tree";
|
|
4
|
+
function flowStatePath() {
|
|
5
|
+
return `${RUNTIME_ROOT}/state/flow-state.json`;
|
|
6
|
+
}
|
|
7
|
+
function delegationLogPath() {
|
|
8
|
+
return `${RUNTIME_ROOT}/state/delegation-log.json`;
|
|
9
|
+
}
|
|
10
|
+
function artifactsPath() {
|
|
11
|
+
return `${RUNTIME_ROOT}/artifacts`;
|
|
12
|
+
}
|
|
13
|
+
function rewindLogPath() {
|
|
14
|
+
return `${RUNTIME_ROOT}/state/rewind-log.jsonl`;
|
|
15
|
+
}
|
|
16
|
+
export function treeCommandContract() {
|
|
17
|
+
return `# /cc-tree
|
|
18
|
+
|
|
19
|
+
## Purpose
|
|
20
|
+
|
|
21
|
+
Render a visual flow tree for quick orientation across stages, gates, delegations,
|
|
22
|
+
stale markers, and artifact presence.
|
|
23
|
+
|
|
24
|
+
## HARD-GATE
|
|
25
|
+
|
|
26
|
+
- \`/cc-tree\` is read-only. Do not mutate flow-state or artifacts.
|
|
27
|
+
- Use values from \`${flowStatePath()}\` and \`${delegationLogPath()}\`; never infer missing evidence.
|
|
28
|
+
|
|
29
|
+
## Algorithm
|
|
30
|
+
|
|
31
|
+
1. Read \`${flowStatePath()}\`.
|
|
32
|
+
2. Read \`${delegationLogPath()}\` (if missing, treat current-stage delegations as pending).
|
|
33
|
+
3. Detect artifact files in \`${artifactsPath()}\`.
|
|
34
|
+
4. Read rewind records from \`${rewindLogPath()}\` when present for stale-stage context.
|
|
35
|
+
5. Render the tree using stage order from active track:
|
|
36
|
+
- stage node marker: passed/current/pending/skipped/stale
|
|
37
|
+
- gate summary: \`passed/required\`
|
|
38
|
+
- delegation summary for current stage
|
|
39
|
+
- artifact marker per stage (exists / stale copy / missing)
|
|
40
|
+
|
|
41
|
+
## Tree Format
|
|
42
|
+
|
|
43
|
+
\`\`\`
|
|
44
|
+
cclaw flow tree (track=<track>, run=<runId>)
|
|
45
|
+
├─ [✓] brainstorm gates 6/6 artifact 01-brainstorm.md
|
|
46
|
+
├─ [✓] scope gates 5/5 artifact 02-scope.md
|
|
47
|
+
├─ [▶] design gates 2/7 artifact 03-design.md
|
|
48
|
+
│ ├─ delegations: [✓] planner [○] reviewer
|
|
49
|
+
│ └─ stale: none
|
|
50
|
+
├─ [○] spec gates - artifact missing
|
|
51
|
+
└─ [○] plan gates - artifact missing
|
|
52
|
+
\`\`\`
|
|
53
|
+
|
|
54
|
+
Use UTF markers by default, ASCII fallback when terminal cannot render UTF.
|
|
55
|
+
|
|
56
|
+
## Primary skill
|
|
57
|
+
|
|
58
|
+
**${RUNTIME_ROOT}/skills/${TREE_SKILL_FOLDER}/SKILL.md**
|
|
59
|
+
`;
|
|
60
|
+
}
|
|
61
|
+
export function treeCommandSkillMarkdown() {
|
|
62
|
+
return `---
|
|
63
|
+
name: ${TREE_SKILL_NAME}
|
|
64
|
+
description: "Render a visual flow tree for stages, gates, delegations, and artifacts."
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
# /cc-tree
|
|
68
|
+
|
|
69
|
+
## HARD-GATE
|
|
70
|
+
|
|
71
|
+
Do not modify state in this command. It is a pure read/render operation.
|
|
72
|
+
|
|
73
|
+
## Protocol
|
|
74
|
+
|
|
75
|
+
1. Read \`${flowStatePath()}\` as source of truth.
|
|
76
|
+
2. Read \`${delegationLogPath()}\` for current-stage delegation status.
|
|
77
|
+
3. Inspect \`${artifactsPath()}\` for per-stage artifact presence and stale copies.
|
|
78
|
+
4. Render one compact tree:
|
|
79
|
+
- stage marker: passed/current/pending/skipped/stale
|
|
80
|
+
- gates summary
|
|
81
|
+
- artifact summary
|
|
82
|
+
- delegation branch for current stage
|
|
83
|
+
5. If rewind records exist in \`${rewindLogPath()}\`, include latest rewind note in footer.
|
|
84
|
+
|
|
85
|
+
## Validation
|
|
86
|
+
|
|
87
|
+
- Output must mention the active \`track\` and \`currentStage\`.
|
|
88
|
+
- Exactly one stage is marked current.
|
|
89
|
+
- Missing files are reported explicitly; never guessed as complete.
|
|
90
|
+
`;
|
|
91
|
+
}
|
package/dist/doctor.js
CHANGED
|
@@ -14,8 +14,10 @@ import { readFlowState } from "./runs.js";
|
|
|
14
14
|
import { skippedStagesForTrack } from "./flow-state.js";
|
|
15
15
|
import { TRACK_STAGES } from "./types.js";
|
|
16
16
|
import { checkMandatoryDelegations } from "./delegation.js";
|
|
17
|
+
import { ensureFeatureSystem, featureRootPath, listFeatures, readActiveFeature } from "./feature-system.js";
|
|
17
18
|
import { buildTraceMatrix } from "./trace-matrix.js";
|
|
18
19
|
import { reconcileAndWriteCurrentStageGateCatalog, verifyCompletedStagesGateClosure, verifyCurrentStageGateEvidence } from "./gate-evidence.js";
|
|
20
|
+
import { parseTddCycleLog, validateTddCycleOrder } from "./tdd-cycle.js";
|
|
19
21
|
import { stageSkillFolder } from "./content/skills.js";
|
|
20
22
|
import { doctorCheckMetadata } from "./doctor-registry.js";
|
|
21
23
|
import { LANGUAGE_RULE_PACK_DIR, LANGUAGE_RULE_PACK_FILES, LEGACY_LANGUAGE_RULE_PACK_FOLDERS, UTILITY_SKILL_FOLDERS } from "./content/utility-skills.js";
|
|
@@ -445,7 +447,19 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
445
447
|
});
|
|
446
448
|
continue;
|
|
447
449
|
}
|
|
448
|
-
for (const shim of [
|
|
450
|
+
for (const shim of [
|
|
451
|
+
"cc.md",
|
|
452
|
+
"cc-next.md",
|
|
453
|
+
"cc-learn.md",
|
|
454
|
+
"cc-status.md",
|
|
455
|
+
"cc-tree.md",
|
|
456
|
+
"cc-diff.md",
|
|
457
|
+
"cc-feature.md",
|
|
458
|
+
"cc-tdd-log.md",
|
|
459
|
+
"cc-retro.md",
|
|
460
|
+
"cc-rewind.md",
|
|
461
|
+
"cc-rewind-ack.md"
|
|
462
|
+
]) {
|
|
449
463
|
const shimPath = path.join(projectRoot, adapter.commandDir, shim);
|
|
450
464
|
checks.push({
|
|
451
465
|
name: `shim:${harness}:${shim.replace(".md", "")}`,
|
|
@@ -462,10 +476,32 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
462
476
|
const hasCcCommand = content.includes("/cc");
|
|
463
477
|
const hasCcNext = content.includes("/cc-next");
|
|
464
478
|
const hasCcLearn = content.includes("/cc-learn");
|
|
479
|
+
const hasCcStatus = content.includes("/cc-status");
|
|
480
|
+
const hasCcTree = content.includes("/cc-tree");
|
|
481
|
+
const hasCcDiff = content.includes("/cc-diff");
|
|
482
|
+
const hasCcFeature = content.includes("/cc-feature");
|
|
483
|
+
const hasCcTddLog = content.includes("/cc-tdd-log");
|
|
484
|
+
const hasCcRetro = content.includes("/cc-retro");
|
|
485
|
+
const hasCcRewind = content.includes("/cc-rewind");
|
|
486
|
+
const hasCcRewindAck = content.includes("/cc-rewind-ack");
|
|
465
487
|
const hasVerification = content.includes("Verification Discipline");
|
|
466
488
|
const hasMinimalMarker = content.includes("intentionally minimal for cross-project use");
|
|
467
489
|
const hasMetaSkillPointer = content.includes(".cclaw/skills/using-cclaw/SKILL.md");
|
|
468
|
-
agentsBlockOk = hasMarkers
|
|
490
|
+
agentsBlockOk = hasMarkers
|
|
491
|
+
&& hasCcCommand
|
|
492
|
+
&& hasCcNext
|
|
493
|
+
&& hasCcLearn
|
|
494
|
+
&& hasCcStatus
|
|
495
|
+
&& hasCcTree
|
|
496
|
+
&& hasCcDiff
|
|
497
|
+
&& hasCcFeature
|
|
498
|
+
&& hasCcTddLog
|
|
499
|
+
&& hasCcRetro
|
|
500
|
+
&& hasCcRewind
|
|
501
|
+
&& hasCcRewindAck
|
|
502
|
+
&& hasVerification
|
|
503
|
+
&& hasMinimalMarker
|
|
504
|
+
&& hasMetaSkillPointer;
|
|
469
505
|
}
|
|
470
506
|
checks.push({
|
|
471
507
|
name: "agents:cclaw_block",
|
|
@@ -473,7 +509,7 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
473
509
|
details: `${agentsFile} must contain the managed cclaw marker block with routing, verification, and minimal detail pointer`
|
|
474
510
|
});
|
|
475
511
|
// Utility commands
|
|
476
|
-
for (const cmd of ["learn"]) {
|
|
512
|
+
for (const cmd of ["learn", "next", "status", "tree", "diff", "feature", "tdd-log", "retro", "rewind", "rewind-ack"]) {
|
|
477
513
|
const cmdPath = path.join(projectRoot, RUNTIME_ROOT, "commands", `${cmd}.md`);
|
|
478
514
|
checks.push({
|
|
479
515
|
name: `utility_command:${cmd}`,
|
|
@@ -484,6 +520,12 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
484
520
|
// Utility skills
|
|
485
521
|
for (const [folder, label] of [
|
|
486
522
|
["learnings", "learnings"],
|
|
523
|
+
["flow-tree", "flow-tree"],
|
|
524
|
+
["flow-diff", "flow-diff"],
|
|
525
|
+
["feature-workspaces", "feature-workspaces"],
|
|
526
|
+
["tdd-cycle-log", "tdd-cycle-log"],
|
|
527
|
+
["flow-retro", "flow-retro"],
|
|
528
|
+
["flow-rewind", "flow-rewind"],
|
|
487
529
|
["subagent-dev", "sdd"],
|
|
488
530
|
["parallel-dispatch", "parallel-agents"],
|
|
489
531
|
["session", "session"],
|
|
@@ -937,6 +979,8 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
937
979
|
: "preamble log is empty; no recent preamble emissions recorded"
|
|
938
980
|
});
|
|
939
981
|
}
|
|
982
|
+
await ensureFeatureSystem(projectRoot);
|
|
983
|
+
const activeFeature = await readActiveFeature(projectRoot);
|
|
940
984
|
let flowState = await readFlowState(projectRoot);
|
|
941
985
|
if (options.reconcileCurrentStageGates === true) {
|
|
942
986
|
const reconciliation = await reconcileAndWriteCurrentStageGateCatalog(projectRoot);
|
|
@@ -991,6 +1035,108 @@ export async function doctorChecks(projectRoot, options = {}) {
|
|
|
991
1035
|
ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "artifacts")),
|
|
992
1036
|
details: `${RUNTIME_ROOT}/artifacts must exist as the active artifact root`
|
|
993
1037
|
});
|
|
1038
|
+
const features = await listFeatures(projectRoot);
|
|
1039
|
+
checks.push({
|
|
1040
|
+
name: "state:active_feature_meta",
|
|
1041
|
+
ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "state", "active-feature.json")),
|
|
1042
|
+
details: `${RUNTIME_ROOT}/state/active-feature.json must exist`
|
|
1043
|
+
});
|
|
1044
|
+
checks.push({
|
|
1045
|
+
name: "state:active_feature_exists",
|
|
1046
|
+
ok: features.includes(activeFeature),
|
|
1047
|
+
details: features.includes(activeFeature)
|
|
1048
|
+
? `active feature "${activeFeature}" is present in ${RUNTIME_ROOT}/features`
|
|
1049
|
+
: `active feature "${activeFeature}" is missing from ${RUNTIME_ROOT}/features`
|
|
1050
|
+
});
|
|
1051
|
+
checks.push({
|
|
1052
|
+
name: "state:features_nonempty",
|
|
1053
|
+
ok: features.length > 0,
|
|
1054
|
+
details: features.length > 0
|
|
1055
|
+
? `${features.length} feature snapshot(s): ${features.join(", ")}`
|
|
1056
|
+
: `no feature snapshots found under ${RUNTIME_ROOT}/features`
|
|
1057
|
+
});
|
|
1058
|
+
checks.push({
|
|
1059
|
+
name: "state:active_feature_snapshot_dirs",
|
|
1060
|
+
ok: await exists(path.join(featureRootPath(projectRoot, activeFeature), "artifacts")) &&
|
|
1061
|
+
await exists(path.join(featureRootPath(projectRoot, activeFeature), "state")),
|
|
1062
|
+
details: `${RUNTIME_ROOT}/features/${activeFeature}/artifacts and /state must exist`
|
|
1063
|
+
});
|
|
1064
|
+
const staleStages = Object.keys(flowState.staleStages).filter((value) => COMMAND_FILE_ORDER.includes(value));
|
|
1065
|
+
checks.push({
|
|
1066
|
+
name: "state:stale_stages_resolved",
|
|
1067
|
+
ok: staleStages.length === 0,
|
|
1068
|
+
details: staleStages.length === 0
|
|
1069
|
+
? "no stale stages pending acknowledgement"
|
|
1070
|
+
: `stale stages must be acknowledged via /cc-rewind-ack: ${staleStages.join(", ")}`
|
|
1071
|
+
});
|
|
1072
|
+
const retroRequired = flowState.completedStages.includes("ship");
|
|
1073
|
+
const retroComplete = !retroRequired ||
|
|
1074
|
+
(typeof flowState.retro.completedAt === "string" && flowState.retro.compoundEntries > 0);
|
|
1075
|
+
checks.push({
|
|
1076
|
+
name: "state:retro_gate",
|
|
1077
|
+
ok: retroComplete,
|
|
1078
|
+
details: retroComplete
|
|
1079
|
+
? retroRequired
|
|
1080
|
+
? `retro gate complete (${flowState.retro.compoundEntries} compound entries)`
|
|
1081
|
+
: "retro gate not required yet (ship not completed)"
|
|
1082
|
+
: "retro gate incomplete: run /cc-retro and record at least one compound knowledge entry"
|
|
1083
|
+
});
|
|
1084
|
+
const flowSnapshotPath = path.join(projectRoot, RUNTIME_ROOT, "state", "flow-state.snapshot.json");
|
|
1085
|
+
const flowSnapshotExists = await exists(flowSnapshotPath);
|
|
1086
|
+
let flowSnapshotValid = flowSnapshotExists;
|
|
1087
|
+
if (flowSnapshotExists) {
|
|
1088
|
+
try {
|
|
1089
|
+
JSON.parse(await fs.readFile(flowSnapshotPath, "utf8"));
|
|
1090
|
+
flowSnapshotValid = true;
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
flowSnapshotValid = false;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
checks.push({
|
|
1097
|
+
name: "state:flow_snapshot",
|
|
1098
|
+
ok: flowSnapshotExists && flowSnapshotValid,
|
|
1099
|
+
details: flowSnapshotExists
|
|
1100
|
+
? flowSnapshotValid
|
|
1101
|
+
? `${RUNTIME_ROOT}/state/flow-state.snapshot.json exists and is valid JSON`
|
|
1102
|
+
: `${RUNTIME_ROOT}/state/flow-state.snapshot.json exists but is invalid JSON`
|
|
1103
|
+
: `${RUNTIME_ROOT}/state/flow-state.snapshot.json is missing`
|
|
1104
|
+
});
|
|
1105
|
+
const tddLogPath = path.join(projectRoot, RUNTIME_ROOT, "state", "tdd-cycle-log.jsonl");
|
|
1106
|
+
const tddLogExists = await exists(tddLogPath);
|
|
1107
|
+
checks.push({
|
|
1108
|
+
name: "state:tdd_cycle_log_exists",
|
|
1109
|
+
ok: tddLogExists,
|
|
1110
|
+
details: `${RUNTIME_ROOT}/state/tdd-cycle-log.jsonl must exist`
|
|
1111
|
+
});
|
|
1112
|
+
const tddCompleted = flowState.completedStages.includes("tdd")
|
|
1113
|
+
|| (flowState.currentStage === "review" || flowState.currentStage === "ship");
|
|
1114
|
+
if (tddLogExists) {
|
|
1115
|
+
const tddLogRaw = await fs.readFile(tddLogPath, "utf8");
|
|
1116
|
+
const parsedCycles = parseTddCycleLog(tddLogRaw);
|
|
1117
|
+
const validation = validateTddCycleOrder(parsedCycles, { runId: activeRunId || undefined });
|
|
1118
|
+
const hasCoverage = validation.sliceCount > 0;
|
|
1119
|
+
checks.push({
|
|
1120
|
+
name: "state:tdd_cycle_order",
|
|
1121
|
+
ok: validation.ok && (!tddCompleted || hasCoverage),
|
|
1122
|
+
details: validation.ok
|
|
1123
|
+
? tddCompleted && !hasCoverage
|
|
1124
|
+
? "tdd stage complete but no RED/GREEN cycle evidence logged"
|
|
1125
|
+
: `tdd cycle log valid (${validation.sliceCount} slice(s), open_red=${validation.openRedSlices.length})`
|
|
1126
|
+
: `tdd cycle order issues: ${validation.issues.join("; ")}${validation.openRedSlices.length > 0
|
|
1127
|
+
? ` | open red slices: ${validation.openRedSlices.join(", ")}`
|
|
1128
|
+
: ""}`
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
checks.push({
|
|
1133
|
+
name: "state:tdd_cycle_order",
|
|
1134
|
+
ok: !tddCompleted,
|
|
1135
|
+
details: tddCompleted
|
|
1136
|
+
? "tdd stage complete but tdd-cycle-log.jsonl is missing"
|
|
1137
|
+
: "tdd cycle order deferred until tdd stage evidence is generated"
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
994
1140
|
checks.push({
|
|
995
1141
|
name: "runs:archive_root",
|
|
996
1142
|
ok: await exists(path.join(projectRoot, RUNTIME_ROOT, "runs")),
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface ActiveFeatureMeta {
|
|
2
|
+
activeFeature: string;
|
|
3
|
+
updatedAt: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function activeFeatureMetaPath(projectRoot: string): string;
|
|
6
|
+
export declare function featureRootPath(projectRoot: string, featureId: string): string;
|
|
7
|
+
export declare function featureArtifactsPath(projectRoot: string, featureId: string): string;
|
|
8
|
+
export declare function featureStatePath(projectRoot: string, featureId: string): string;
|
|
9
|
+
export declare function readActiveFeature(projectRoot: string): Promise<string>;
|
|
10
|
+
export declare function listFeatures(projectRoot: string): Promise<string[]>;
|
|
11
|
+
export declare function ensureFeatureSystem(projectRoot: string): Promise<ActiveFeatureMeta>;
|
|
12
|
+
export declare function syncActiveFeatureSnapshot(projectRoot: string): Promise<void>;
|
|
13
|
+
export declare function switchActiveFeature(projectRoot: string, featureId: string): Promise<ActiveFeatureMeta>;
|
|
14
|
+
export interface CreateFeatureOptions {
|
|
15
|
+
cloneActive?: boolean;
|
|
16
|
+
switchTo?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export declare function createFeature(projectRoot: string, rawFeatureId: string, options?: CreateFeatureOptions): Promise<string>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { RUNTIME_ROOT } from "./constants.js";
|
|
4
|
+
import { ensureDir, exists, writeFileSafe } from "./fs-utils.js";
|
|
5
|
+
const FEATURES_DIR_REL_PATH = `${RUNTIME_ROOT}/features`;
|
|
6
|
+
const ACTIVE_FEATURE_META_REL_PATH = `${RUNTIME_ROOT}/state/active-feature.json`;
|
|
7
|
+
const DEFAULT_FEATURE_ID = "default";
|
|
8
|
+
const FEATURE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u;
|
|
9
|
+
const FEATURE_STATE_EXCLUDE_FROM_SNAPSHOT = new Set([
|
|
10
|
+
"active-feature.json",
|
|
11
|
+
".flow-state.lock",
|
|
12
|
+
".delegation.lock"
|
|
13
|
+
]);
|
|
14
|
+
function featuresRoot(projectRoot) {
|
|
15
|
+
return path.join(projectRoot, FEATURES_DIR_REL_PATH);
|
|
16
|
+
}
|
|
17
|
+
function runtimeArtifactsRoot(projectRoot) {
|
|
18
|
+
return path.join(projectRoot, RUNTIME_ROOT, "artifacts");
|
|
19
|
+
}
|
|
20
|
+
function runtimeStateRoot(projectRoot) {
|
|
21
|
+
return path.join(projectRoot, RUNTIME_ROOT, "state");
|
|
22
|
+
}
|
|
23
|
+
export function activeFeatureMetaPath(projectRoot) {
|
|
24
|
+
return path.join(projectRoot, ACTIVE_FEATURE_META_REL_PATH);
|
|
25
|
+
}
|
|
26
|
+
export function featureRootPath(projectRoot, featureId) {
|
|
27
|
+
return path.join(featuresRoot(projectRoot), featureId);
|
|
28
|
+
}
|
|
29
|
+
export function featureArtifactsPath(projectRoot, featureId) {
|
|
30
|
+
return path.join(featureRootPath(projectRoot, featureId), "artifacts");
|
|
31
|
+
}
|
|
32
|
+
export function featureStatePath(projectRoot, featureId) {
|
|
33
|
+
return path.join(featureRootPath(projectRoot, featureId), "state");
|
|
34
|
+
}
|
|
35
|
+
function normalizedFeatureId(value) {
|
|
36
|
+
const candidate = value
|
|
37
|
+
.trim()
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/gu, "-")
|
|
40
|
+
.replace(/^-+/u, "")
|
|
41
|
+
.replace(/-+$/u, "");
|
|
42
|
+
if (!candidate) {
|
|
43
|
+
return DEFAULT_FEATURE_ID;
|
|
44
|
+
}
|
|
45
|
+
const clipped = candidate.slice(0, 64);
|
|
46
|
+
return FEATURE_ID_PATTERN.test(clipped) ? clipped : DEFAULT_FEATURE_ID;
|
|
47
|
+
}
|
|
48
|
+
async function clearDirectory(dirPath, preserveTargetEntries = new Set()) {
|
|
49
|
+
await ensureDir(dirPath);
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (preserveTargetEntries.has(entry.name)) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
await fs.rm(path.join(dirPath, entry.name), { recursive: true, force: true });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function copyDirectoryContents(sourceDir, targetDir, options = {}) {
|
|
65
|
+
const exclude = options.exclude ?? new Set();
|
|
66
|
+
const preserveTargetEntries = options.preserveTargetEntries ?? new Set();
|
|
67
|
+
await ensureDir(targetDir);
|
|
68
|
+
await clearDirectory(targetDir, preserveTargetEntries);
|
|
69
|
+
if (!(await exists(sourceDir))) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
if (exclude.has(entry.name)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const from = path.join(sourceDir, entry.name);
|
|
84
|
+
const to = path.join(targetDir, entry.name);
|
|
85
|
+
if (entry.isDirectory()) {
|
|
86
|
+
await fs.cp(from, to, { recursive: true, force: true });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (entry.isFile()) {
|
|
90
|
+
await fs.copyFile(from, to);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function dirHasEntries(dirPath, exclude = new Set()) {
|
|
95
|
+
if (!(await exists(dirPath))) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
100
|
+
return entries.some((entry) => !exclude.has(entry.name));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function readActiveFeatureMetaInternal(projectRoot) {
|
|
107
|
+
const filePath = activeFeatureMetaPath(projectRoot);
|
|
108
|
+
if (!(await exists(filePath))) {
|
|
109
|
+
return {
|
|
110
|
+
activeFeature: DEFAULT_FEATURE_ID,
|
|
111
|
+
updatedAt: new Date().toISOString()
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
116
|
+
const activeFeatureRaw = typeof parsed.activeFeature === "string"
|
|
117
|
+
? parsed.activeFeature
|
|
118
|
+
: DEFAULT_FEATURE_ID;
|
|
119
|
+
const updatedAtRaw = typeof parsed.updatedAt === "string"
|
|
120
|
+
? parsed.updatedAt
|
|
121
|
+
: new Date().toISOString();
|
|
122
|
+
return {
|
|
123
|
+
activeFeature: normalizedFeatureId(activeFeatureRaw),
|
|
124
|
+
updatedAt: updatedAtRaw
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return {
|
|
129
|
+
activeFeature: DEFAULT_FEATURE_ID,
|
|
130
|
+
updatedAt: new Date().toISOString()
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async function writeActiveFeatureMeta(projectRoot, meta) {
|
|
135
|
+
const normalized = {
|
|
136
|
+
activeFeature: normalizedFeatureId(meta.activeFeature),
|
|
137
|
+
updatedAt: meta.updatedAt
|
|
138
|
+
};
|
|
139
|
+
await writeFileSafe(activeFeatureMetaPath(projectRoot), `${JSON.stringify(normalized, null, 2)}\n`);
|
|
140
|
+
}
|
|
141
|
+
async function ensureFeatureSnapshot(projectRoot, featureId) {
|
|
142
|
+
const id = normalizedFeatureId(featureId);
|
|
143
|
+
await ensureDir(featureArtifactsPath(projectRoot, id));
|
|
144
|
+
await ensureDir(featureStatePath(projectRoot, id));
|
|
145
|
+
}
|
|
146
|
+
export async function readActiveFeature(projectRoot) {
|
|
147
|
+
const meta = await readActiveFeatureMetaInternal(projectRoot);
|
|
148
|
+
return normalizedFeatureId(meta.activeFeature);
|
|
149
|
+
}
|
|
150
|
+
export async function listFeatures(projectRoot) {
|
|
151
|
+
const root = featuresRoot(projectRoot);
|
|
152
|
+
if (!(await exists(root))) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
let entries;
|
|
156
|
+
try {
|
|
157
|
+
entries = await fs.readdir(root, { withFileTypes: true });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
return entries
|
|
163
|
+
.filter((entry) => entry.isDirectory() && FEATURE_ID_PATTERN.test(entry.name))
|
|
164
|
+
.map((entry) => entry.name)
|
|
165
|
+
.sort((a, b) => a.localeCompare(b));
|
|
166
|
+
}
|
|
167
|
+
export async function ensureFeatureSystem(projectRoot) {
|
|
168
|
+
await ensureDir(featuresRoot(projectRoot));
|
|
169
|
+
await ensureDir(runtimeArtifactsRoot(projectRoot));
|
|
170
|
+
await ensureDir(runtimeStateRoot(projectRoot));
|
|
171
|
+
const existing = await readActiveFeatureMetaInternal(projectRoot);
|
|
172
|
+
const activeFeature = normalizedFeatureId(existing.activeFeature);
|
|
173
|
+
await ensureFeatureSnapshot(projectRoot, activeFeature);
|
|
174
|
+
const runtimeArtifactsHasData = await dirHasEntries(runtimeArtifactsRoot(projectRoot));
|
|
175
|
+
const runtimeStateHasData = await dirHasEntries(runtimeStateRoot(projectRoot), new Set(["active-feature.json"]));
|
|
176
|
+
const featureArtifactsHasData = await dirHasEntries(featureArtifactsPath(projectRoot, activeFeature));
|
|
177
|
+
const featureStateHasData = await dirHasEntries(featureStatePath(projectRoot, activeFeature));
|
|
178
|
+
if ((runtimeArtifactsHasData || runtimeStateHasData) && !featureArtifactsHasData && !featureStateHasData) {
|
|
179
|
+
await copyDirectoryContents(runtimeArtifactsRoot(projectRoot), featureArtifactsPath(projectRoot, activeFeature));
|
|
180
|
+
await copyDirectoryContents(runtimeStateRoot(projectRoot), featureStatePath(projectRoot, activeFeature), { exclude: FEATURE_STATE_EXCLUDE_FROM_SNAPSHOT });
|
|
181
|
+
}
|
|
182
|
+
else if ((!runtimeArtifactsHasData && !runtimeStateHasData) && (featureArtifactsHasData || featureStateHasData)) {
|
|
183
|
+
await copyDirectoryContents(featureArtifactsPath(projectRoot, activeFeature), runtimeArtifactsRoot(projectRoot));
|
|
184
|
+
await copyDirectoryContents(featureStatePath(projectRoot, activeFeature), runtimeStateRoot(projectRoot), { preserveTargetEntries: new Set(["active-feature.json"]) });
|
|
185
|
+
}
|
|
186
|
+
const normalized = {
|
|
187
|
+
activeFeature,
|
|
188
|
+
updatedAt: new Date().toISOString()
|
|
189
|
+
};
|
|
190
|
+
await writeActiveFeatureMeta(projectRoot, normalized);
|
|
191
|
+
return normalized;
|
|
192
|
+
}
|
|
193
|
+
export async function syncActiveFeatureSnapshot(projectRoot) {
|
|
194
|
+
const activeFeature = await readActiveFeature(projectRoot);
|
|
195
|
+
await ensureFeatureSnapshot(projectRoot, activeFeature);
|
|
196
|
+
await copyDirectoryContents(runtimeArtifactsRoot(projectRoot), featureArtifactsPath(projectRoot, activeFeature));
|
|
197
|
+
await copyDirectoryContents(runtimeStateRoot(projectRoot), featureStatePath(projectRoot, activeFeature), {
|
|
198
|
+
exclude: FEATURE_STATE_EXCLUDE_FROM_SNAPSHOT
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
export async function switchActiveFeature(projectRoot, featureId) {
|
|
202
|
+
await ensureFeatureSystem(projectRoot);
|
|
203
|
+
const current = await readActiveFeature(projectRoot);
|
|
204
|
+
const target = normalizedFeatureId(featureId);
|
|
205
|
+
if (current === target) {
|
|
206
|
+
const unchanged = {
|
|
207
|
+
activeFeature: current,
|
|
208
|
+
updatedAt: new Date().toISOString()
|
|
209
|
+
};
|
|
210
|
+
await writeActiveFeatureMeta(projectRoot, unchanged);
|
|
211
|
+
return unchanged;
|
|
212
|
+
}
|
|
213
|
+
await syncActiveFeatureSnapshot(projectRoot);
|
|
214
|
+
await ensureFeatureSnapshot(projectRoot, target);
|
|
215
|
+
await copyDirectoryContents(featureArtifactsPath(projectRoot, target), runtimeArtifactsRoot(projectRoot));
|
|
216
|
+
await copyDirectoryContents(featureStatePath(projectRoot, target), runtimeStateRoot(projectRoot), {
|
|
217
|
+
preserveTargetEntries: new Set(["active-feature.json"])
|
|
218
|
+
});
|
|
219
|
+
const nextMeta = {
|
|
220
|
+
activeFeature: target,
|
|
221
|
+
updatedAt: new Date().toISOString()
|
|
222
|
+
};
|
|
223
|
+
await writeActiveFeatureMeta(projectRoot, nextMeta);
|
|
224
|
+
return nextMeta;
|
|
225
|
+
}
|
|
226
|
+
export async function createFeature(projectRoot, rawFeatureId, options = {}) {
|
|
227
|
+
await ensureFeatureSystem(projectRoot);
|
|
228
|
+
const featureId = normalizedFeatureId(rawFeatureId);
|
|
229
|
+
if (featureId === DEFAULT_FEATURE_ID && rawFeatureId.trim().length > 0 && rawFeatureId.trim().toLowerCase() !== "default") {
|
|
230
|
+
throw new Error(`Unable to create feature from "${rawFeatureId}" — use letters, numbers, and dashes.`);
|
|
231
|
+
}
|
|
232
|
+
const featureDir = featureRootPath(projectRoot, featureId);
|
|
233
|
+
if (await exists(featureDir)) {
|
|
234
|
+
throw new Error(`Feature "${featureId}" already exists.`);
|
|
235
|
+
}
|
|
236
|
+
await ensureFeatureSnapshot(projectRoot, featureId);
|
|
237
|
+
if (options.cloneActive === true) {
|
|
238
|
+
const activeFeature = await readActiveFeature(projectRoot);
|
|
239
|
+
await syncActiveFeatureSnapshot(projectRoot);
|
|
240
|
+
await copyDirectoryContents(featureArtifactsPath(projectRoot, activeFeature), featureArtifactsPath(projectRoot, featureId));
|
|
241
|
+
await copyDirectoryContents(featureStatePath(projectRoot, activeFeature), featureStatePath(projectRoot, featureId));
|
|
242
|
+
}
|
|
243
|
+
if (options.switchTo === true) {
|
|
244
|
+
await switchActiveFeature(projectRoot, featureId);
|
|
245
|
+
}
|
|
246
|
+
return featureId;
|
|
247
|
+
}
|
package/dist/flow-state.d.ts
CHANGED
|
@@ -9,6 +9,25 @@ export interface StageGateState {
|
|
|
9
9
|
passed: string[];
|
|
10
10
|
blocked: string[];
|
|
11
11
|
}
|
|
12
|
+
export interface RewindRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
fromStage: FlowStage;
|
|
15
|
+
toStage: FlowStage;
|
|
16
|
+
reason: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
invalidatedStages: FlowStage[];
|
|
19
|
+
}
|
|
20
|
+
export interface StaleStageMarker {
|
|
21
|
+
rewindId: string;
|
|
22
|
+
reason: string;
|
|
23
|
+
markedAt: string;
|
|
24
|
+
acknowledgedAt?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface RetroState {
|
|
27
|
+
required: boolean;
|
|
28
|
+
completedAt?: string;
|
|
29
|
+
compoundEntries: number;
|
|
30
|
+
}
|
|
12
31
|
export interface FlowState {
|
|
13
32
|
activeRunId: string;
|
|
14
33
|
currentStage: FlowStage;
|
|
@@ -19,6 +38,12 @@ export interface FlowState {
|
|
|
19
38
|
track: FlowTrack;
|
|
20
39
|
/** Stages explicitly skipped for this track (empty for standard; populated for quick). */
|
|
21
40
|
skippedStages: FlowStage[];
|
|
41
|
+
/** Stages invalidated by rewind operations and awaiting explicit acknowledgement. */
|
|
42
|
+
staleStages: Partial<Record<FlowStage, StaleStageMarker>>;
|
|
43
|
+
/** Chronological rewind operations for the active run. */
|
|
44
|
+
rewinds: RewindRecord[];
|
|
45
|
+
/** Mandatory retrospective gate status before archive. */
|
|
46
|
+
retro: RetroState;
|
|
22
47
|
}
|
|
23
48
|
export interface InitialFlowStateOptions {
|
|
24
49
|
activeRunId?: string;
|
package/dist/flow-state.js
CHANGED
|
@@ -41,7 +41,14 @@ export function createInitialFlowState(activeRunIdOrOptions = "active", maybeTra
|
|
|
41
41
|
guardEvidence: {},
|
|
42
42
|
stageGateCatalog,
|
|
43
43
|
track,
|
|
44
|
-
skippedStages
|
|
44
|
+
skippedStages,
|
|
45
|
+
staleStages: {},
|
|
46
|
+
rewinds: [],
|
|
47
|
+
retro: {
|
|
48
|
+
required: false,
|
|
49
|
+
completedAt: undefined,
|
|
50
|
+
compoundEntries: 0
|
|
51
|
+
}
|
|
45
52
|
};
|
|
46
53
|
}
|
|
47
54
|
export function canTransition(from, to) {
|