@curdx/flow 2.1.0 → 2.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/.claude-plugin/marketplace.json +25 -2
  2. package/.claude-plugin/plugin.json +27 -1
  3. package/CHANGELOG.md +32 -0
  4. package/README.md +18 -8
  5. package/README.zh.md +8 -3
  6. package/agent-preamble/preamble.md +35 -2
  7. package/agents/flow-adversary.md +1 -1
  8. package/agents/flow-architect.md +2 -1
  9. package/agents/flow-brownfield-analyst.md +153 -0
  10. package/agents/flow-debugger.md +6 -11
  11. package/agents/flow-edge-hunter.md +1 -1
  12. package/agents/flow-executor.md +30 -8
  13. package/agents/flow-planner.md +38 -5
  14. package/agents/flow-product-designer.md +2 -1
  15. package/agents/flow-qa-engineer.md +25 -20
  16. package/agents/flow-researcher.md +2 -1
  17. package/agents/flow-reviewer.md +23 -5
  18. package/agents/flow-security-auditor.md +5 -3
  19. package/agents/flow-triage-analyst.md +5 -24
  20. package/agents/flow-ui-researcher.md +6 -5
  21. package/agents/flow-ux-designer.md +12 -39
  22. package/agents/flow-verifier.md +38 -6
  23. package/bin/curdx-flow +5 -0
  24. package/cli/README.md +13 -10
  25. package/cli/doctor-workflow.js +1074 -2
  26. package/cli/doctor.js +8 -0
  27. package/cli/help.js +2 -0
  28. package/cli/install-companions.js +4 -1
  29. package/cli/install-required-plugins.js +18 -5
  30. package/cli/install-self-update.js +2 -91
  31. package/cli/install.js +12 -1
  32. package/cli/lib/claude.js +42 -11
  33. package/cli/lib/doctor-report.js +303 -9
  34. package/cli/lib/frontmatter.js +44 -0
  35. package/cli/lib/json-schema.js +57 -0
  36. package/cli/lib/runtime.js +20 -2
  37. package/cli/lib/semver.js +95 -0
  38. package/cli/utils.js +7 -1
  39. package/gates/adversarial-review-gate.md +1 -1
  40. package/gates/security-gate.md +2 -2
  41. package/gates/test-quality-gate.md +59 -0
  42. package/hooks/hooks.json +16 -2
  43. package/hooks/scripts/common.sh +4 -0
  44. package/hooks/scripts/quick-mode-guard.sh +6 -7
  45. package/hooks/scripts/session-start.sh +17 -2
  46. package/hooks/scripts/stop-watcher.sh +69 -18
  47. package/hooks/scripts/subagent-artifact-guard.sh +159 -0
  48. package/hooks/scripts/subagent-statusline.sh +105 -0
  49. package/knowledge/atomic-commits.md +1 -1
  50. package/knowledge/claude-code-runtime-contracts.md +203 -0
  51. package/knowledge/epic-decomposition.md +1 -1
  52. package/knowledge/execution-strategies.md +28 -6
  53. package/knowledge/planning-reviews.md +4 -4
  54. package/knowledge/poc-first-workflow.md +8 -8
  55. package/knowledge/review-feedback-intake.md +57 -0
  56. package/knowledge/two-stage-review.md +19 -6
  57. package/knowledge/wave-execution.md +33 -18
  58. package/output-styles/curdx-evidence-first.md +34 -0
  59. package/package.json +9 -2
  60. package/schemas/agent-frontmatter.schema.json +59 -0
  61. package/schemas/config.schema.json +37 -3
  62. package/schemas/gate-frontmatter.schema.json +30 -0
  63. package/schemas/hooks.schema.json +115 -0
  64. package/schemas/output-style-frontmatter.schema.json +22 -0
  65. package/schemas/plugin-manifest.schema.json +436 -0
  66. package/schemas/plugin-settings.schema.json +29 -0
  67. package/schemas/skill-frontmatter.schema.json +177 -0
  68. package/schemas/spec-state.schema.json +35 -5
  69. package/settings.json +6 -0
  70. package/skills/brownfield-index/SKILL.md +33 -36
  71. package/skills/browser-qa/SKILL.md +16 -7
  72. package/skills/cancel/SKILL.md +82 -0
  73. package/skills/debug/SKILL.md +7 -2
  74. package/skills/epic/SKILL.md +7 -4
  75. package/skills/fast/SKILL.md +3 -1
  76. package/skills/help/SKILL.md +18 -7
  77. package/skills/implement/SKILL.md +44 -12
  78. package/skills/implement/references/wave-execution.md +9 -9
  79. package/skills/init/SKILL.md +3 -1
  80. package/skills/review/SKILL.md +6 -2
  81. package/skills/security-audit/SKILL.md +19 -4
  82. package/skills/spec/SKILL.md +6 -4
  83. package/skills/start/SKILL.md +20 -19
  84. package/skills/status/SKILL.md +85 -0
  85. package/skills/ui-sketch/SKILL.md +13 -4
  86. package/skills/verify/SKILL.md +15 -2
  87. package/templates/CONTEXT.md.tmpl +1 -1
  88. package/templates/PROJECT.md.tmpl +1 -1
  89. package/templates/config.json.tmpl +9 -6
  90. package/templates/progress.md.tmpl +21 -2
  91. package/templates/tasks.md.tmpl +26 -3
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from "node:fs";
2
+ import YAML from "yaml";
3
+
4
+ function formatYamlErrors(errors) {
5
+ return errors.map((error) => error.message).join("; ");
6
+ }
7
+
8
+ export function extractFrontmatterBlock(text, sourceLabel = "frontmatter") {
9
+ const match = text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
10
+ if (!match) {
11
+ throw new Error(`${sourceLabel}: missing YAML frontmatter`);
12
+ }
13
+
14
+ return match[1];
15
+ }
16
+
17
+ export function parseFrontmatterBlock(block, sourceLabel = "frontmatter") {
18
+ const document = YAML.parseDocument(block, {
19
+ strict: true,
20
+ uniqueKeys: true,
21
+ });
22
+
23
+ if (document.errors.length > 0) {
24
+ throw new Error(`${sourceLabel}: invalid YAML (${formatYamlErrors(document.errors)})`);
25
+ }
26
+
27
+ const value = document.toJS();
28
+ if (value == null) return {};
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
30
+ throw new Error(`${sourceLabel}: frontmatter must parse to an object`);
31
+ }
32
+
33
+ return value;
34
+ }
35
+
36
+ export function readFrontmatter(filePath) {
37
+ const text = readFileSync(filePath, "utf-8");
38
+ const block = extractFrontmatterBlock(text, filePath);
39
+ return parseFrontmatterBlock(block, filePath);
40
+ }
41
+
42
+ export function readFrontmatterFields(filePath) {
43
+ return Object.keys(readFrontmatter(filePath));
44
+ }
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import Ajv from "ajv";
4
+
5
+ const ajv = new Ajv({
6
+ allErrors: true,
7
+ allowUnionTypes: true,
8
+ strict: false,
9
+ });
10
+
11
+ const validatorCache = new Map();
12
+
13
+ function formatParams(params = {}) {
14
+ if (params.missingProperty) return ` missingProperty=${params.missingProperty}`;
15
+ if (params.additionalProperty) return ` additionalProperty=${params.additionalProperty}`;
16
+ return "";
17
+ }
18
+
19
+ export function readJsonFile(filePath) {
20
+ return JSON.parse(readFileSync(filePath, "utf-8"));
21
+ }
22
+
23
+ export function formatAjvErrors(errors = []) {
24
+ return errors.map((error) => {
25
+ const where = error.instancePath || error.schemaPath || "(root)";
26
+ return `${where}: ${error.message}${formatParams(error.params)}`;
27
+ });
28
+ }
29
+
30
+ function getSchemaValidator(schemaPath) {
31
+ const absPath = resolve(schemaPath);
32
+ if (validatorCache.has(absPath)) return validatorCache.get(absPath);
33
+
34
+ const schema = readJsonFile(absPath);
35
+ const validate = ajv.compile(schema);
36
+ const entry = { absPath, schema, validate };
37
+ validatorCache.set(absPath, entry);
38
+ return entry;
39
+ }
40
+
41
+ export function validateAgainstSchemaFile(schemaPath, data) {
42
+ const { validate } = getSchemaValidator(schemaPath);
43
+ const valid = Boolean(validate(data));
44
+ return {
45
+ valid,
46
+ errors: valid ? [] : formatAjvErrors(validate.errors),
47
+ };
48
+ }
49
+
50
+ export function validateSchemaFile(schemaPath) {
51
+ const schema = readJsonFile(resolve(schemaPath));
52
+ const valid = ajv.validateSchema(schema);
53
+ return {
54
+ valid,
55
+ errors: valid ? [] : formatAjvErrors(ajv.errors),
56
+ };
57
+ }
@@ -52,15 +52,18 @@ function findSymlinkDir() {
52
52
  return null;
53
53
  }
54
54
 
55
- export function ensureRuntimeInPath(cmd, candidates) {
55
+ function getRuntimeStatus(cmd, candidates, { repair = false } = {}) {
56
56
  if (has(cmd)) return { status: "ok" };
57
-
58
57
  const realPath = findRuntime(candidates);
59
58
  if (!realPath) return { status: "missing" };
60
59
 
61
60
  const linkDir = findSymlinkDir();
62
61
  if (!linkDir) return { status: "path-unwritable", path: realPath };
63
62
 
63
+ if (!repair) {
64
+ return { status: "linkable", path: realPath, link: join(linkDir, cmd) };
65
+ }
66
+
64
67
  const linkPath = join(linkDir, cmd);
65
68
  if (existsSync(linkPath)) {
66
69
  try {
@@ -81,6 +84,21 @@ export function ensureRuntimeInPath(cmd, candidates) {
81
84
  }
82
85
  }
83
86
 
87
+ export function inspectRuntimeInPath(cmd, candidates) {
88
+ return getRuntimeStatus(cmd, candidates, { repair: false });
89
+ }
90
+
91
+ export function ensureRuntimeInPath(cmd, candidates) {
92
+ return getRuntimeStatus(cmd, candidates, { repair: true });
93
+ }
94
+
95
+ export function inspectClaudeMemRuntimes() {
96
+ return {
97
+ bun: inspectRuntimeInPath("bun", BUN_CANDIDATES),
98
+ uv: inspectRuntimeInPath("uv", UV_CANDIDATES),
99
+ };
100
+ }
101
+
84
102
  export function ensureClaudeMemRuntimes() {
85
103
  return {
86
104
  bun: ensureRuntimeInPath("bun", BUN_CANDIDATES),
@@ -0,0 +1,95 @@
1
+ function normalizeVersionToken(token) {
2
+ return /^\d+$/.test(token) ? Number(token) : token;
3
+ }
4
+
5
+ function parseVersion(version) {
6
+ const normalized = String(version || "").trim().replace(/^v/i, "");
7
+ const [coreRaw = "0", prereleaseRaw] = normalized.split("-", 2);
8
+ const core = coreRaw.split(".").map((part) => Number.parseInt(part, 10) || 0);
9
+ const prerelease = prereleaseRaw
10
+ ? prereleaseRaw.split(/[.-]/).filter(Boolean).map(normalizeVersionToken)
11
+ : [];
12
+
13
+ return { core, prerelease };
14
+ }
15
+
16
+ function compareIdentifier(left, right) {
17
+ if (left === right) {
18
+ return 0;
19
+ }
20
+
21
+ const leftIsNumber = typeof left === "number";
22
+ const rightIsNumber = typeof right === "number";
23
+
24
+ if (leftIsNumber && rightIsNumber) {
25
+ return left > right ? 1 : -1;
26
+ }
27
+
28
+ if (leftIsNumber) {
29
+ return -1;
30
+ }
31
+
32
+ if (rightIsNumber) {
33
+ return 1;
34
+ }
35
+
36
+ return left > right ? 1 : -1;
37
+ }
38
+
39
+ export function compareVersions(leftVersion, rightVersion) {
40
+ const left = parseVersion(leftVersion);
41
+ const right = parseVersion(rightVersion);
42
+ const coreLength = Math.max(left.core.length, right.core.length);
43
+
44
+ for (let index = 0; index < coreLength; index += 1) {
45
+ const leftPart = left.core[index] ?? 0;
46
+ const rightPart = right.core[index] ?? 0;
47
+ if (leftPart !== rightPart) {
48
+ return leftPart > rightPart ? 1 : -1;
49
+ }
50
+ }
51
+
52
+ const leftHasPrerelease = left.prerelease.length > 0;
53
+ const rightHasPrerelease = right.prerelease.length > 0;
54
+
55
+ if (!leftHasPrerelease && !rightHasPrerelease) {
56
+ return 0;
57
+ }
58
+
59
+ if (!leftHasPrerelease) {
60
+ return 1;
61
+ }
62
+
63
+ if (!rightHasPrerelease) {
64
+ return -1;
65
+ }
66
+
67
+ const prereleaseLength = Math.max(left.prerelease.length, right.prerelease.length);
68
+ for (let index = 0; index < prereleaseLength; index += 1) {
69
+ const leftPart = left.prerelease[index];
70
+ const rightPart = right.prerelease[index];
71
+
72
+ if (leftPart === undefined) {
73
+ return -1;
74
+ }
75
+
76
+ if (rightPart === undefined) {
77
+ return 1;
78
+ }
79
+
80
+ const comparison = compareIdentifier(leftPart, rightPart);
81
+ if (comparison !== 0) {
82
+ return comparison;
83
+ }
84
+ }
85
+
86
+ return 0;
87
+ }
88
+
89
+ export function isVersionNewer(latestVersion, currentVersion) {
90
+ return compareVersions(latestVersion, currentVersion) > 0;
91
+ }
92
+
93
+ export function isVersionAtLeast(version, minimumVersion) {
94
+ return compareVersions(version, minimumVersion) >= 0;
95
+ }
package/cli/utils.js CHANGED
@@ -29,6 +29,12 @@ export {
29
29
  listPluginMarketplaces,
30
30
  listPlugins,
31
31
  parseMcpList,
32
+ parsePluginListJson,
32
33
  readUserMcpConfig,
33
34
  } from "./lib/claude.js";
34
- export { ensureClaudeMemRuntimes, ensureRuntimeInPath } from "./lib/runtime.js";
35
+ export {
36
+ ensureClaudeMemRuntimes,
37
+ ensureRuntimeInPath,
38
+ inspectClaudeMemRuntimes,
39
+ inspectRuntimeInPath,
40
+ } from "./lib/runtime.js";
@@ -17,7 +17,7 @@ depends_on: []
17
17
 
18
18
  - /curdx-flow:review command
19
19
  - Before Phase transitions (requirements → design, design → tasks)
20
- - Before code merge (/curdx-flow:ship)
20
+ - Before code merge or human PR/release handoff
21
21
  - Enabled by default in Enterprise mode
22
22
 
23
23
  ---
@@ -14,7 +14,7 @@ depends_on: []
14
14
  ## Trigger Timing
15
15
 
16
16
  - When the `security-audit` skill runs
17
- - Before `/curdx-flow:ship` (auto-triggered, Phase 6+)
17
+ - Before human PR/release handoff, after `/curdx-flow:verify` and `/curdx-flow:review`
18
18
  - When committing specs involving auth / payments / PII
19
19
 
20
20
  ---
@@ -154,7 +154,7 @@ pnpm audit
154
154
 
155
155
  ### Blocking Items
156
156
 
157
- - If SR-01 ~ SR-05 are found → block immediately, prohibit `/curdx-flow:ship`
157
+ - If SR-01 ~ SR-05 are found → block immediately; do not hand off for PR/release
158
158
  - Must fix or explicitly exempt (record in STATE.md as tech debt + commitment to fix before release)
159
159
 
160
160
  ### Warning Items
@@ -0,0 +1,59 @@
1
+ ---
2
+ gate: test-quality-gate
3
+ category: standard-mode
4
+ severity: blocking
5
+ depends_on: []
6
+ ---
7
+
8
+ # Test Quality Gate
9
+
10
+ A green test suite is not enough. Tests must exercise real behavior and fail for the right reason.
11
+
12
+ ## Blocking Findings
13
+
14
+ Flag as blocking when a test is the only evidence for an FR/AC and any of these hold:
15
+
16
+ 1. **Mock-only behavior**
17
+ - Assertions only check mock calls (`toHaveBeenCalled`, `calledWith`, spy counts).
18
+ - The real module/function under test is never invoked.
19
+ - The test would still pass if the production implementation were empty.
20
+
21
+ 2. **Mock setup dominates evidence**
22
+ - Mock/stub/spy setup lines are more than 3x real behavioral assertions.
23
+ - The test mostly restates fixture wiring instead of asserting output, state, persistence, or user-visible behavior.
24
+
25
+ 3. **Skipped or inert tests**
26
+ - `it.skip`, `describe.skip`, `test.skip`, `xit`, `pending`, or equivalent on covered behavior.
27
+ - Test has no assertions and no meaningful side-effect check.
28
+
29
+ 4. **Implementation-biased regression**
30
+ - Test was added after implementation without evidence of RED failure when the task claims TDD.
31
+ - Test asserts internal private structure instead of externally observable behavior.
32
+
33
+ 5. **Missing cleanup for stateful mocks**
34
+ - Stateful mocks/spies are used across tests without `afterEach` cleanup (`restoreAllMocks`, `clearAllMocks`, sandbox restore, etc.).
35
+ - Shared mock state can leak between tests.
36
+
37
+ ## Acceptable Mock Usage
38
+
39
+ Mocks are acceptable when they isolate a boundary and the assertion still verifies real behavior:
40
+
41
+ - Network/payment/email provider mocked, but service logic and error handling are real.
42
+ - Clock/randomness mocked to make deterministic assertions.
43
+ - Database mocked only when a separate integration test covers persistence behavior.
44
+
45
+ ## Evidence Checklist
46
+
47
+ For each FR/AC test evidence, record:
48
+
49
+ - Test file and test name.
50
+ - What real code path is invoked.
51
+ - What behavioral assertion proves the requirement.
52
+ - Whether the test was observed RED before GREEN when TDD is claimed.
53
+ - Whether mocks are boundary-only or behavior-replacing.
54
+
55
+ ## Verdicts
56
+
57
+ - `PASS`: Tests exercise real behavior with meaningful assertions.
58
+ - `WARN`: Mock-heavy but supported by separate integration/e2e coverage.
59
+ - `FAIL`: Mock-only/skipped/no-assertion test is used as primary evidence.
package/hooks/hooks.json CHANGED
@@ -5,7 +5,8 @@
5
5
  "hooks": [
6
6
  {
7
7
  "type": "command",
8
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.sh"
8
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/session-start.sh",
9
+ "statusMessage": "Loading CurDX-Flow project context"
9
10
  }
10
11
  ]
11
12
  },
@@ -14,7 +15,8 @@
14
15
  "hooks": [
15
16
  {
16
17
  "type": "command",
17
- "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/inject-karpathy.sh"
18
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/inject-karpathy.sh",
19
+ "statusMessage": "Injecting CurDX-Flow engineering baseline"
18
20
  }
19
21
  ]
20
22
  }
@@ -29,6 +31,18 @@
29
31
  ]
30
32
  }
31
33
  ],
34
+ "SubagentStop": [
35
+ {
36
+ "matcher": "flow-(architect|brownfield-analyst|debugger|edge-hunter|executor|product-designer|planner|qa-engineer|researcher|reviewer|security-auditor|triage-analyst|ui-researcher|ux-designer|verifier|adversary)",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/subagent-artifact-guard.sh",
41
+ "statusMessage": "Checking curdx-flow artifact landing"
42
+ }
43
+ ]
44
+ }
45
+ ],
32
46
  "PreToolUse": [
33
47
  {
34
48
  "matcher": "AskUserQuestion",
@@ -33,3 +33,7 @@ emit_stop_block() {
33
33
  local reason="${1:-}"
34
34
  printf '{"decision":"block","reason":%s}\n' "$(json_escape "$reason")"
35
35
  }
36
+
37
+ emit_subagentstop_block() {
38
+ emit_stop_block "${1:-}"
39
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # CurDX-Flow PreToolUse Hook for AskUserQuestion
3
- # Blocks AskUserQuestion when the active spec has quickMode=true or mode=autonomous.
4
- # This prevents the autonomous loop from stalling waiting for user input.
3
+ # Blocks AskUserQuestion when the active spec has quickMode=true.
4
+ # This prevents quick execution loops from stalling waiting for user input.
5
5
  #
6
6
  # The hook reads Claude's PreToolUse input JSON from stdin. We only act when
7
7
  # the tool being invoked is AskUserQuestion.
@@ -34,7 +34,7 @@ if [ "$TOOL_NAME" != "AskUserQuestion" ]; then
34
34
  exit 0
35
35
  fi
36
36
 
37
- # Check if we're in a flow project with quick mode enabled
37
+ # Check if we're in a flow project with quick mode enabled.
38
38
  [ ! -d ".flow" ] && exit 0
39
39
 
40
40
  ACTIVE=$(cat .flow/.active-spec 2>/dev/null)
@@ -43,7 +43,7 @@ ACTIVE=$(cat .flow/.active-spec 2>/dev/null)
43
43
  STATE_FILE=".flow/specs/$ACTIVE/.state.json"
44
44
  [ ! -f "$STATE_FILE" ] && exit 0
45
45
 
46
- # Read quickMode + mode. Pass STATE_FILE via env (NOT shell interpolation
46
+ # Read quickMode. Pass STATE_FILE via env (NOT shell interpolation
47
47
  # into the python source) so an active-spec name containing quotes/$ cannot
48
48
  # inject python code.
49
49
  export STATE_FILE
@@ -52,15 +52,14 @@ import json, os
52
52
  try:
53
53
  s = json.load(open(os.environ["STATE_FILE"]))
54
54
  qm = s.get("quickMode", False)
55
- mode = s.get("mode", "")
56
- print("true" if (qm or mode == "autonomous") else "false")
55
+ print("true" if qm else "false")
57
56
  except Exception:
58
57
  print("false")
59
58
  ' 2>/dev/null)
60
59
 
61
60
  if [ "$QUICK_MODE" = "true" ]; then
62
61
  # Block and inject guidance
63
- MSG="[CurDX-Flow quick-mode-guard] Active spec '$ACTIVE' is in quick mode or autonomous mode — AskUserQuestion is forbidden. Decide autonomously based on user preferences in .flow/CONTEXT.md plus the most reasonable assumption, and record your assumption in .progress.md."
62
+ MSG="[CurDX-Flow quick-mode-guard] Active spec '$ACTIVE' is in quick mode — AskUserQuestion is forbidden. Decide based on user preferences in .flow/CONTEXT.md plus the most reasonable assumption, and record your assumption in .progress.md."
64
63
  emit_pretooluse_deny "$MSG"
65
64
  exit 0
66
65
  fi
@@ -3,6 +3,7 @@
3
3
  # Duties:
4
4
  # 1. Daily dependency check — nudge user to `npx @curdx/flow install --all` if recommended plugins missing
5
5
  # 2. Load active spec progress into session context
6
+ # 3. Persist stable CurDX-Flow environment hints for this session
6
7
  #
7
8
  # Design notes:
8
9
  # - Idempotent: marker file tracks last check date
@@ -45,12 +46,15 @@ if [ "$LAST_CHECK" != "$TODAY" ]; then
45
46
  ADDITIONAL_CONTEXT+="## CurDX-Flow Recommended Plugins Check\n\nThe following recommended plugins were not detected: **${JOINED}**\n\nRun \`npx @curdx/flow install --all\` for interactive one-shot install. Run \`npx @curdx/flow doctor\` for the full health report.\n\n"
46
47
  fi
47
48
 
48
- echo "$TODAY" > "$MARKER" 2>/dev/null || true
49
+ { echo "$TODAY" > "$MARKER"; } 2>/dev/null || true
49
50
  fi
50
51
 
51
52
  # ---------- 2. Load .flow/ state (if project is a flow project) ----------
52
53
  if [ -d ".flow" ]; then
53
54
  ADDITIONAL_CONTEXT+="## CurDX-Flow Project Active\n\n"
55
+ ADDITIONAL_CONTEXT+="- Plugin root: \`${CLAUDE_PLUGIN_ROOT:-unknown}\`\n"
56
+ ADDITIONAL_CONTEXT+="- Plugin data: \`${CLAUDE_PLUGIN_DATA:-$DATA_DIR}\`\n"
57
+ ADDITIONAL_CONTEXT+="- Best practice: write long agent artifacts to disk first; keep final assistant summaries short.\n\n"
54
58
 
55
59
  if [ -f ".flow/PROJECT.md" ]; then
56
60
  ADDITIONAL_CONTEXT+="### Project Vision\n$(head -80 .flow/PROJECT.md)\n\n"
@@ -67,7 +71,18 @@ if [ -d ".flow" ]; then
67
71
  fi
68
72
  fi
69
73
 
70
- # ---------- 3. Emit hook output ----------
74
+ # ---------- 3. Persist session environment hints ----------
75
+ if [ -n "${CLAUDE_ENV_FILE:-}" ]; then
76
+ {
77
+ printf 'export CURDX_FLOW_PLUGIN_ROOT=%s\n' "$(json_escape "${CLAUDE_PLUGIN_ROOT:-}")"
78
+ printf 'export CURDX_FLOW_PLUGIN_DATA=%s\n' "$(json_escape "${CLAUDE_PLUGIN_DATA:-$DATA_DIR}")"
79
+ if [ -f ".flow/.active-spec" ]; then
80
+ printf 'export CURDX_FLOW_ACTIVE_SPEC=%s\n' "$(json_escape "$(cat .flow/.active-spec 2>/dev/null)")"
81
+ fi
82
+ } >> "$CLAUDE_ENV_FILE" 2>/dev/null || true
83
+ fi
84
+
85
+ # ---------- 4. Emit hook output ----------
71
86
  if [ -n "$ADDITIONAL_CONTEXT" ]; then
72
87
  emit_session_start_context "$ADDITIONAL_CONTEXT"
73
88
  fi
@@ -57,7 +57,7 @@ fi
57
57
  # the stop-hook strategy never activated.
58
58
  export STATE_FILE
59
59
 
60
- read STRATEGY PHASE TASK_INDEX TOTAL_TASKS FAILED ROUNDS <<EOF
60
+ read STRATEGY PHASE TASK_INDEX TOTAL_TASKS FAILED ROUNDS RECOVERY_MODE MAX_FIX_TASKS <<EOF
61
61
  $(python3 <<'PY'
62
62
  import json, os, sys
63
63
  p = os.environ.get("STATE_FILE")
@@ -72,7 +72,9 @@ ti = ex.get("task_index", 0)
72
72
  tt = ex.get("total_tasks", 0)
73
73
  failed = ex.get("failed_attempts", 0)
74
74
  rounds = ex.get("global_iteration", 0)
75
- print(strategy, phase, ti, tt, failed, rounds)
75
+ recovery_mode = ex.get("recovery_mode", "manual")
76
+ max_fix_tasks = ex.get("max_fix_tasks_per_original", 2)
77
+ print(strategy, phase, ti, tt, failed, rounds, recovery_mode, max_fix_tasks)
76
78
  PY
77
79
  )
78
80
  EOF
@@ -81,7 +83,7 @@ EOF
81
83
  [ "$STRATEGY" != "stop-hook" ] && allow_stop
82
84
  [ "$PHASE" != "execute" ] && allow_stop
83
85
 
84
- # ---------- 5. Check for completion signal in transcript ----------
86
+ # ---------- 5. Check hook input + completion signal in transcript ----------
85
87
  # Claude Code passes transcript path via stdin as JSON: {"transcript_path": "/path/..."}
86
88
  # We read stdin to detect ALL_TASKS_COMPLETE or TASK_FAILED
87
89
  INPUT=$(cat 2>/dev/null || echo "{}")
@@ -89,6 +91,19 @@ TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c 'import json,sys;
89
91
  try: print(json.load(sys.stdin).get("transcript_path",""))
90
92
  except: print("")' 2>/dev/null)
91
93
 
94
+ STOP_HOOK_ACTIVE=$(echo "$INPUT" | python3 -c 'import json,sys;
95
+ try: print("true" if json.load(sys.stdin).get("stop_hook_active", False) else "false")
96
+ except: print("false")' 2>/dev/null)
97
+
98
+ if [ "$STOP_HOOK_ACTIVE" = "true" ]; then
99
+ # Claude Code sets stop_hook_active during a stop-hook continuation.
100
+ # Treat it as context only: the final decision still comes from transcript
101
+ # signals, state-file progress, and tasks.md parity. Unconditionally allowing
102
+ # stop here can terminate an in-flight stop-hook loop after the first
103
+ # continuation, leaving remaining tasks stranded.
104
+ echo "[CurDX-Flow stop-hook] stop_hook_active=true; evaluating transcript/state before deciding" >&2
105
+ fi
106
+
92
107
  TRANSCRIPT_TAIL=""
93
108
  if [ -n "$TRANSCRIPT_PATH" ] && [ -f "$TRANSCRIPT_PATH" ]; then
94
109
  # Read last 50KB only (efficiency)
@@ -100,22 +115,54 @@ fi
100
115
  # python source text. Previously a spec name containing single quotes or
101
116
  # $-signs could break the script or inject arbitrary code.
102
117
 
103
- # Check for explicit completion signals
104
- if echo "$TRANSCRIPT_TAIL" | grep -q "ALL_TASKS_COMPLETE"; then
105
- # Cleanup: mark phase completed
118
+ # Helper: count unchecked tasks in tasks.md. If tasks.md is absent, return 0
119
+ # to avoid blocking recovery for partially-initialized specs.
120
+ unchecked_task_count() {
121
+ local tasks_file="$SPEC_DIR/tasks.md"
122
+ [ ! -f "$tasks_file" ] && { echo 0; return; }
123
+ grep -Ec '^- \[ \] \*\*[0-9]+(\.[0-9]+|\.VF|\.X|\.X\+1)*\*\*' "$tasks_file" 2>/dev/null || echo 0
124
+ }
125
+
126
+ last_task_signal() {
127
+ local msg="${1:-}"
128
+ printf '%s' "$msg" \
129
+ | grep -Eo 'ALL_TASKS_COMPLETE|TASK_(COMPLETE|FAILED):[[:space:]]*[0-9]+(\.([0-9]+|VF|X(\+[0-9]+)?))*' \
130
+ | tail -1
131
+ }
132
+
133
+ failed_task_id() {
134
+ local msg="${1:-}"
135
+ printf '%s' "$msg" | sed -nE 's/.*TASK_FAILED:[[:space:]]*([0-9]+(\.([0-9]+|VF|X(\+[0-9]+)?))*).*/\1/p' | tail -1
136
+ }
137
+
138
+ mark_execute_complete() {
106
139
  python3 <<'PY' 2>/dev/null
107
140
  import json, os
108
141
  p = os.environ["STATE_FILE"]
109
142
  s = json.load(open(p))
110
143
  s.setdefault("phase_status", {})["execute"] = "completed"
111
- s["phase"] = "verify" # move to verify phase
144
+ s["phase"] = "verify"
112
145
  json.dump(s, open(p, "w"), indent=2, ensure_ascii=False)
113
146
  PY
147
+ }
148
+
149
+ # Check for explicit completion signals
150
+ LAST_TASK_SIGNAL="$(last_task_signal "$TRANSCRIPT_TAIL")"
151
+
152
+ if [ "$LAST_TASK_SIGNAL" = "ALL_TASKS_COMPLETE" ]; then
153
+ UNCHECKED="$(unchecked_task_count)"
154
+ if [ "${UNCHECKED:-0}" -gt 0 ]; then
155
+ block_continue "[CurDX-Flow stop-hook] ALL_TASKS_COMPLETE was emitted, but tasks.md still has ${UNCHECKED} unchecked task(s). Read .flow/specs/${ACTIVE}/tasks.md, complete only the remaining unchecked tasks, update tasks.md, then emit ALL_TASKS_COMPLETE again."
156
+ fi
157
+ mark_execute_complete
114
158
  allow_stop
115
159
  fi
116
160
 
117
- # Check for fail signal (accumulate; actual stop decision below)
118
- if echo "$TRANSCRIPT_TAIL" | grep -q "TASK_FAILED"; then
161
+ # Check for the latest fail signal (accumulate; actual stop decision below)
162
+ if printf '%s' "$LAST_TASK_SIGNAL" | grep -q "^TASK_FAILED"; then
163
+ FAILED_TASK="$(failed_task_id "$LAST_TASK_SIGNAL")"
164
+ [ -z "$FAILED_TASK" ] && FAILED_TASK="the current task"
165
+
119
166
  # Increment failed_attempts
120
167
  python3 <<'PY' 2>/dev/null
121
168
  import json, os
@@ -127,6 +174,14 @@ json.dump(s, open(p, "w"), indent=2, ensure_ascii=False)
127
174
  PY
128
175
  # Re-read — again via os.environ, no shell interpolation into python.
129
176
  FAILED=$(python3 -c 'import json, os; print(json.load(open(os.environ["STATE_FILE"]))["execute_state"]["failed_attempts"])' 2>/dev/null || echo 0)
177
+
178
+ if [ "${FAILED:-0}" -lt 3 ]; then
179
+ if [ "${RECOVERY_MODE:-manual}" = "fix-task" ]; then
180
+ block_continue "[CurDX-Flow stop-hook] TASK_FAILED observed for ${FAILED_TASK}. Do not skip it. Recovery mode is fix-task: insert one targeted [FIX ${FAILED_TASK}] task immediately after the failed task in tasks.md (max ${MAX_FIX_TASKS:-2} fix task(s) per original), update .state.json execute_state.fix_task_map, then execute the fix task before retrying ${FAILED_TASK}. The fix task must include Do, Files, Done when, Verify, and Commit fields."
181
+ fi
182
+
183
+ block_continue "[CurDX-Flow stop-hook] TASK_FAILED observed for ${FAILED_TASK}. Do not advance past the failed task. Re-read tasks.md, perform root-cause analysis, retry the first unchecked task, and emit TASK_COMPLETE only after its Verify command passes. failed_attempts=${FAILED}/3."
184
+ fi
130
185
  fi
131
186
 
132
187
  # ---------- 6. Safety brakes ----------
@@ -142,15 +197,11 @@ fi
142
197
 
143
198
  # Check if all tasks done
144
199
  if [ "$TASK_INDEX" -ge "$TOTAL_TASKS" ] && [ "$TOTAL_TASKS" -gt 0 ]; then
145
- # Mark complete
146
- python3 <<'PY' 2>/dev/null
147
- import json, os
148
- p = os.environ["STATE_FILE"]
149
- s = json.load(open(p))
150
- s.setdefault("phase_status", {})["execute"] = "completed"
151
- s["phase"] = "verify"
152
- json.dump(s, open(p, "w"), indent=2, ensure_ascii=False)
153
- PY
200
+ UNCHECKED="$(unchecked_task_count)"
201
+ if [ "${UNCHECKED:-0}" -gt 0 ]; then
202
+ block_continue "[CurDX-Flow stop-hook] State says execute is complete (${TASK_INDEX}/${TOTAL_TASKS}), but tasks.md still has ${UNCHECKED} unchecked task(s). Continue with the first unchecked task; do not add new tasks."
203
+ fi
204
+ mark_execute_complete
154
205
  allow_stop
155
206
  fi
156
207