cadence-skill-installer 0.2.8 → 0.2.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadence-skill-installer",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Install the Cadence skill into supported AI tool skill directories.",
5
5
  "repository": "https://github.com/snowdamiz/cadence",
6
6
  "private": false,
package/skill/SKILL.md CHANGED
@@ -37,24 +37,31 @@ description: Structured project operating system for end-to-end greenfield or br
37
37
  3. Scaffold initializes and persists `state.cadence-scripts-dir` for later subskill commands.
38
38
  4. If `.cadence` exists, skip scaffold.
39
39
 
40
- ## Prerequisite Gate (Mandatory On First Turn)
41
- 1. Invoke `skills/prerequisite-gate/SKILL.md`.
42
- 2. Continue lifecycle and delivery execution only after prerequisite gate pass.
40
+ ## Workflow Route Gate (Mandatory Every Turn)
41
+ 1. After scaffold handling, run `python3 scripts/read-workflow-state.py` and parse the JSON response.
42
+ 2. Treat `next_item` and `route.skill_name` from that response as the authoritative workflow route.
43
+ 3. Do not invoke a state-changing subskill unless it matches `route.skill_name`.
44
+
45
+ ## Prerequisite Gate (Conditional)
46
+ 1. Invoke `skills/prerequisite-gate/SKILL.md` only when `route.skill_name` is `prerequisite-gate`.
47
+ 2. If `route.skill_name` is not `prerequisite-gate` (for example `ideator`), skip prerequisite gate and follow the active route instead.
43
48
 
44
49
  ## Progress / Resume Flow
45
50
  1. Invoke `skills/project-progress/SKILL.md` when the user asks to continue/resume or requests progress status (for example: "continue the project", "how far along are we?", "where did we leave off?").
46
51
  2. Use that skill's state-based routing result to continue from the correct next phase.
47
52
 
48
53
  ## Manual Subskill Safety Gate
49
- 1. If the user manually requests a Cadence subskill, run `python3 scripts/assert-workflow-route.py --skill-name <subskill>` before executing it.
50
- 2. If route assertion fails, stop and surface the exact script error.
51
- 3. Do not execute state-changing subskill steps when assertion fails.
54
+ 1. If the user manually requests a Cadence subskill, first resolve `PROJECT_ROOT` with `python3 scripts/resolve-project-root.py`.
55
+ 2. Run `python3 scripts/assert-workflow-route.py --skill-name <subskill> --project-root "$PROJECT_ROOT"` before executing that subskill.
56
+ 3. If route assertion fails, stop and surface the exact script error.
57
+ 4. Do not execute state-changing subskill steps when assertion fails.
52
58
 
53
59
  ## Ideation Flow
54
- 1. Do not switch to `skills/ideator/SKILL.md` inside this conversation.
55
- 2. After scaffold and prerequisite gates pass for a net-new project, hand off to a fresh chat so context resets cleanly.
56
- 3. Tell the user: `Start a new chat and either say "help me define my project" or share your project brief.`
57
- 4. Stop here and wait for the user to continue in the new chat.
60
+ 1. When scaffold and prerequisite both complete in this same conversation for a net-new project, hand off to a fresh chat so context resets cleanly.
61
+ 2. Use this exact handoff line: `Start a new chat and either say "help me define my project" or share your project brief.`
62
+ 3. In subsequent conversations, if the workflow route is `ideator`, do not rerun prerequisite gate.
63
+ 4. If the user asks to define the project or provides a brief while route is `ideator`, invoke `skills/ideator/SKILL.md`.
64
+ 5. If route is `ideator` and the user has not provided ideation input yet, prompt with the same handoff line and wait.
58
65
 
59
66
  ## Ideation Update Flow
60
67
  1. If the user wants to modify or discuss existing ideation, invoke `skills/ideation-updater/SKILL.md`.
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Cadence"
3
3
  short_description: "Lifecycle + delivery system for structured project execution"
4
- default_prompt: "Use Cadence to guide this project from lifecycle setup through phased execution, traceability, audit, and milestone completion. Always read and apply the active SOUL persona from .cadence/SOUL.json (fallback: SOUL.json). Keep user-facing responses concise and outcome-focused, and never expose internal skill-routing or command-execution traces unless the user explicitly asks. If user intent indicates resuming/continuing work or asking progress, invoke skills/project-progress/SKILL.md first, report current phase, then route to the next step. If the user manually requests a Cadence subskill, run scripts/assert-workflow-route.py with that skill name first and block state-changing execution on mismatch. For net-new project starts, after scaffold and prerequisite gates pass, do not switch skills in-thread; tell the user: Start a new chat and either say \"help me define my project\" or share your project brief."
4
+ default_prompt: "Use Cadence to guide this project from lifecycle setup through phased execution, traceability, audit, and milestone completion. Always read and apply the active SOUL persona from .cadence/SOUL.json (fallback: SOUL.json). Keep user-facing responses concise and outcome-focused, and never expose internal skill-routing or command-execution traces unless the user explicitly asks. At the start of each Cadence turn, run scripts/check-project-repo-status.py, run scaffold only when .cadence is missing, then run scripts/read-workflow-state.py and treat route.skill_name as authoritative for the next state-changing skill. Invoke skills/prerequisite-gate/SKILL.md only when route.skill_name is prerequisite-gate. If route.skill_name is ideator, do not run prerequisite again. For net-new project starts where scaffold and prerequisite just completed in-thread, hand off with: Start a new chat and either say \"help me define my project\" or share your project brief. In later chats, if route.skill_name is ideator and the user asks to define the project or provides a brief, invoke skills/ideator/SKILL.md. If user intent indicates resuming/continuing work or asking progress, invoke skills/project-progress/SKILL.md first, report current phase, then route to the next step. If the user manually requests a Cadence subskill, resolve PROJECT_ROOT with scripts/resolve-project-root.py and then run scripts/assert-workflow-route.py --skill-name <subskill> --project-root \"$PROJECT_ROOT\" before any state-changing actions."
@@ -3,70 +3,81 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import argparse
6
7
  import copy
7
8
  import json
8
9
  import sys
9
10
  from pathlib import Path
10
11
 
12
+ from project_root import resolve_project_root, write_project_root_hint
11
13
  from workflow_state import default_data, reconcile_workflow_state
12
14
 
13
15
 
14
- CADENCE_DIR = Path(".cadence")
15
- CADENCE_JSON_PATH = CADENCE_DIR / "cadence.json"
16
+ SCRIPT_DIR = Path(__file__).resolve().parent
16
17
 
17
18
 
18
- def load_state() -> dict:
19
- cadence_exists = CADENCE_DIR.exists()
19
+ def load_state(project_root: Path, requested_skill: str) -> dict:
20
+ cadence_dir = project_root / ".cadence"
21
+ cadence_json_path = cadence_dir / "cadence.json"
22
+ cadence_exists = cadence_dir.exists()
20
23
 
21
- if not CADENCE_JSON_PATH.exists():
24
+ if not cadence_json_path.exists():
25
+ if requested_skill != "scaffold":
26
+ print(
27
+ f"MISSING_CADENCE_STATE: project_root={project_root}",
28
+ file=sys.stderr,
29
+ )
30
+ raise SystemExit(2)
22
31
  data = default_data()
23
32
  return reconcile_workflow_state(data, cadence_dir_exists=cadence_exists)
24
33
 
25
34
  try:
26
- with CADENCE_JSON_PATH.open("r", encoding="utf-8") as file:
35
+ with cadence_json_path.open("r", encoding="utf-8") as file:
27
36
  original_data = json.load(file)
28
37
  except json.JSONDecodeError as exc:
29
- print(f"INVALID_CADENCE_JSON: {exc}", file=sys.stderr)
38
+ print(f"INVALID_CADENCE_JSON: {exc} path={cadence_json_path}", file=sys.stderr)
30
39
  raise SystemExit(1)
31
40
 
32
41
  return reconcile_workflow_state(copy.deepcopy(original_data), cadence_dir_exists=cadence_exists)
33
42
 
34
43
 
35
- def parse_args(argv: list[str]) -> tuple[str, bool]:
36
- if not argv or len(argv) > 3:
37
- print("Usage: assert-workflow-route.py --skill-name <name> [--allow-complete]", file=sys.stderr)
38
- raise SystemExit(2)
39
-
40
- skill_name = ""
41
- allow_complete = False
42
- idx = 0
43
- while idx < len(argv):
44
- token = argv[idx]
45
- if token == "--skill-name":
46
- if idx + 1 >= len(argv):
47
- print("MISSING_SKILL_NAME", file=sys.stderr)
48
- raise SystemExit(2)
49
- skill_name = str(argv[idx + 1]).strip()
50
- idx += 2
51
- continue
52
- if token == "--allow-complete":
53
- allow_complete = True
54
- idx += 1
55
- continue
56
-
57
- print(f"UNKNOWN_ARGUMENT: {token}", file=sys.stderr)
58
- raise SystemExit(2)
44
+ def parse_args() -> argparse.Namespace:
45
+ parser = argparse.ArgumentParser(
46
+ description="Assert that a requested Cadence skill matches the workflow route.",
47
+ )
48
+ parser.add_argument("--skill-name", required=True, help="Requested subskill name.")
49
+ parser.add_argument(
50
+ "--allow-complete",
51
+ action="store_true",
52
+ help="Allow success when workflow is already complete.",
53
+ )
54
+ parser.add_argument(
55
+ "--project-root",
56
+ default="",
57
+ help="Explicit project root path override.",
58
+ )
59
+ return parser.parse_args()
59
60
 
60
- if not skill_name:
61
- print("MISSING_SKILL_NAME", file=sys.stderr)
62
- raise SystemExit(2)
63
61
 
64
- return skill_name, allow_complete
62
+ def main() -> int:
63
+ args = parse_args()
64
+ requested_skill = str(args.skill_name).strip()
65
+ allow_complete = bool(args.allow_complete)
66
+ explicit_project_root = args.project_root.strip() or None
65
67
 
68
+ try:
69
+ project_root, root_source = resolve_project_root(
70
+ script_dir=SCRIPT_DIR,
71
+ explicit_project_root=explicit_project_root,
72
+ require_cadence=False,
73
+ allow_hint=True,
74
+ )
75
+ except ValueError as exc:
76
+ print(str(exc), file=sys.stderr)
77
+ return 2
66
78
 
67
- def main() -> int:
68
- requested_skill, allow_complete = parse_args(sys.argv[1:])
69
- data = load_state()
79
+ write_project_root_hint(SCRIPT_DIR, project_root)
80
+ data = load_state(project_root, requested_skill)
70
81
 
71
82
  workflow = data.get("workflow", {})
72
83
  next_item = workflow.get("next_item", {})
@@ -87,7 +98,8 @@ def main() -> int:
87
98
  "WORKFLOW_ROUTE_MISMATCH: "
88
99
  f"expected={expected} "
89
100
  f"requested={requested_skill} "
90
- f"next_item={next_item_id}"
101
+ f"next_item={next_item_id} "
102
+ f"project_root={project_root}"
91
103
  ),
92
104
  file=sys.stderr,
93
105
  )
@@ -102,6 +114,8 @@ def main() -> int:
102
114
  "next_item_id": next_item_id,
103
115
  "next_item_title": next_item_title,
104
116
  "workflow_complete": next_item_id == "complete",
117
+ "project_root": str(project_root),
118
+ "project_root_source": root_source,
105
119
  }
106
120
  )
107
121
  )
@@ -11,11 +11,13 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import Any
13
13
 
14
+ from project_root import write_project_root_hint
14
15
  from workflow_state import default_data, reconcile_workflow_state
15
16
 
16
17
 
17
18
  CADENCE_DIR = Path(".cadence")
18
19
  CADENCE_JSON_PATH = CADENCE_DIR / "cadence.json"
20
+ SCRIPT_DIR = Path(__file__).resolve().parent
19
21
 
20
22
 
21
23
  def run_command(command: list[str], cwd: Path) -> subprocess.CompletedProcess[str]:
@@ -140,6 +142,7 @@ def ensure_default_state(data: dict[str, Any]) -> dict[str, Any]:
140
142
  def main() -> int:
141
143
  args = parse_args()
142
144
  project_root = Path(args.project_root).resolve()
145
+ write_project_root_hint(SCRIPT_DIR, project_root)
143
146
 
144
147
  repo_status = detect_git_repo(project_root)
145
148
  cadence_exists = (project_root / CADENCE_JSON_PATH).exists()
@@ -1,49 +1,80 @@
1
1
  #!/usr/bin/env python3
2
2
  """Read or update prerequisite pass state in .cadence/cadence.json."""
3
3
 
4
+ import argparse
4
5
  import json
5
- import sys
6
6
  from pathlib import Path
7
7
 
8
+ from project_root import resolve_project_root, write_project_root_hint
8
9
  from workflow_state import default_data, reconcile_workflow_state
9
10
 
10
11
 
11
- CADENCE_JSON_PATH = Path(".cadence") / "cadence.json"
12
+ SCRIPT_DIR = Path(__file__).resolve().parent
12
13
 
13
14
 
14
- def load_data():
15
- if not CADENCE_JSON_PATH.exists():
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description="Read or update prerequisites-pass in cadence.json.",
18
+ )
19
+ parser.add_argument(
20
+ "pass_state",
21
+ nargs="?",
22
+ choices=("0", "1"),
23
+ help="Set prerequisites-pass to 0 or 1. Omit to read current value.",
24
+ )
25
+ parser.add_argument(
26
+ "--project-root",
27
+ default="",
28
+ help="Explicit project root path override.",
29
+ )
30
+ return parser.parse_args()
31
+
32
+
33
+ def cadence_json_path(project_root: Path) -> Path:
34
+ return project_root / ".cadence" / "cadence.json"
35
+
36
+
37
+ def load_data(project_root: Path):
38
+ state_path = cadence_json_path(project_root)
39
+ if not state_path.exists():
16
40
  return default_data()
17
- with CADENCE_JSON_PATH.open("r", encoding="utf-8") as file:
41
+ with state_path.open("r", encoding="utf-8") as file:
18
42
  data = json.load(file)
19
- return reconcile_workflow_state(data, cadence_dir_exists=CADENCE_JSON_PATH.parent.exists())
43
+ return reconcile_workflow_state(data, cadence_dir_exists=state_path.parent.exists())
20
44
 
21
45
 
22
- def save_data(data):
23
- CADENCE_JSON_PATH.parent.mkdir(parents=True, exist_ok=True)
24
- with CADENCE_JSON_PATH.open("w", encoding="utf-8") as file:
46
+ def save_data(project_root: Path, data):
47
+ state_path = cadence_json_path(project_root)
48
+ state_path.parent.mkdir(parents=True, exist_ok=True)
49
+ with state_path.open("w", encoding="utf-8") as file:
25
50
  json.dump(data, file, indent=4)
26
51
  file.write("\n")
27
52
 
28
53
 
29
54
  def main():
30
- if len(sys.argv) > 2:
31
- print("Usage: handle-prerequisite-state.py [0|1]", file=sys.stderr)
32
- return 2
33
-
34
- if len(sys.argv) == 2 and sys.argv[1] not in {"0", "1"}:
35
- print("Usage: handle-prerequisite-state.py [0|1]", file=sys.stderr)
36
- return 2
37
-
38
- data = load_data()
39
-
40
- if len(sys.argv) == 1:
55
+ args = parse_args()
56
+ explicit_project_root = args.project_root.strip() or None
57
+ try:
58
+ project_root, _ = resolve_project_root(
59
+ script_dir=SCRIPT_DIR,
60
+ explicit_project_root=explicit_project_root,
61
+ require_cadence=False,
62
+ allow_hint=True,
63
+ )
64
+ except ValueError as exc:
65
+ print(str(exc))
66
+ return 1
67
+
68
+ write_project_root_hint(SCRIPT_DIR, project_root)
69
+ data = load_data(project_root)
70
+
71
+ if args.pass_state is None:
41
72
  print("true" if bool(data.get("prerequisites-pass", False)) else "false")
42
73
  return 0
43
74
 
44
- data["prerequisites-pass"] = sys.argv[1] == "1"
45
- data = reconcile_workflow_state(data, cadence_dir_exists=CADENCE_JSON_PATH.parent.exists())
46
- save_data(data)
75
+ data["prerequisites-pass"] = args.pass_state == "1"
76
+ data = reconcile_workflow_state(data, cadence_dir_exists=cadence_json_path(project_root).parent.exists())
77
+ save_data(project_root, data)
47
78
  print("true" if data["prerequisites-pass"] else "false")
48
79
  return 0
49
80
 
@@ -1,43 +1,79 @@
1
1
  #!/usr/bin/env python3
2
2
  """Persist Cadence helper scripts directory in .cadence/cadence.json."""
3
3
 
4
+ import argparse
4
5
  import json
5
6
  from pathlib import Path
6
7
 
8
+ from project_root import resolve_project_root, write_project_root_hint
7
9
  from workflow_state import default_data, reconcile_workflow_state
8
10
 
9
11
 
10
- CADENCE_DIR = Path(".cadence")
11
- CADENCE_JSON_PATH = CADENCE_DIR / "cadence.json"
12
+ SCRIPT_DIR = Path(__file__).resolve().parent
12
13
 
13
14
 
14
- def load_data():
15
- if not CADENCE_JSON_PATH.exists():
15
+ def parse_args() -> argparse.Namespace:
16
+ parser = argparse.ArgumentParser(
17
+ description="Persist cadence-scripts-dir in a project cadence.json.",
18
+ )
19
+ parser.add_argument(
20
+ "--project-root",
21
+ default="",
22
+ help="Explicit project root path override.",
23
+ )
24
+ return parser.parse_args()
25
+
26
+
27
+ def cadence_paths(project_root: Path) -> tuple[Path, Path]:
28
+ cadence_dir = project_root / ".cadence"
29
+ cadence_json_path = cadence_dir / "cadence.json"
30
+ return cadence_dir, cadence_json_path
31
+
32
+
33
+ def load_data(project_root: Path):
34
+ cadence_dir, cadence_json_path = cadence_paths(project_root)
35
+ if not cadence_json_path.exists():
16
36
  return default_data()
17
- with CADENCE_JSON_PATH.open("r", encoding="utf-8") as file:
37
+ with cadence_json_path.open("r", encoding="utf-8") as file:
18
38
  data = json.load(file)
19
- return reconcile_workflow_state(data, cadence_dir_exists=CADENCE_DIR.exists())
39
+ return reconcile_workflow_state(data, cadence_dir_exists=cadence_dir.exists())
20
40
 
21
41
 
22
- def save_data(data):
23
- CADENCE_DIR.mkdir(parents=True, exist_ok=True)
24
- with CADENCE_JSON_PATH.open("w", encoding="utf-8") as file:
42
+ def save_data(project_root: Path, data):
43
+ cadence_dir, cadence_json_path = cadence_paths(project_root)
44
+ cadence_dir.mkdir(parents=True, exist_ok=True)
45
+ with cadence_json_path.open("w", encoding="utf-8") as file:
25
46
  json.dump(data, file, indent=4)
26
47
  file.write("\n")
27
48
 
28
49
 
29
50
  def main():
30
- if not CADENCE_DIR.exists():
51
+ args = parse_args()
52
+ explicit_project_root = args.project_root.strip() or None
53
+ try:
54
+ project_root, _ = resolve_project_root(
55
+ script_dir=SCRIPT_DIR,
56
+ explicit_project_root=explicit_project_root,
57
+ require_cadence=False,
58
+ allow_hint=True,
59
+ )
60
+ except ValueError as exc:
61
+ print(str(exc))
62
+ return 1
63
+
64
+ cadence_dir, _ = cadence_paths(project_root)
65
+ if not cadence_dir.exists():
31
66
  print("MISSING_CADENCE_DIR")
32
67
  return 1
33
68
 
34
- scripts_dir = str(Path(__file__).resolve().parent)
69
+ scripts_dir = str(SCRIPT_DIR)
35
70
 
36
- data = load_data()
71
+ data = load_data(project_root)
37
72
  state = data.setdefault("state", {})
38
73
  state["cadence-scripts-dir"] = scripts_dir
39
- data = reconcile_workflow_state(data, cadence_dir_exists=CADENCE_DIR.exists())
40
- save_data(data)
74
+ data = reconcile_workflow_state(data, cadence_dir_exists=cadence_dir.exists())
75
+ save_data(project_root, data)
76
+ write_project_root_hint(SCRIPT_DIR, project_root)
41
77
 
42
78
  print(json.dumps({"status": "ok", "cadence_scripts_dir": scripts_dir}))
43
79
  return 0
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Project-root resolution and persistence helpers for Cadence scripts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+
9
+ PROJECT_ROOT_HINT_FILE = ".last-project-root"
10
+
11
+
12
+ def hint_file_path(script_dir: Path) -> Path:
13
+ return script_dir / PROJECT_ROOT_HINT_FILE
14
+
15
+
16
+ def write_project_root_hint(script_dir: Path, project_root: Path) -> None:
17
+ """Best-effort write of the most recent Cadence project root."""
18
+ try:
19
+ hint_path = hint_file_path(script_dir)
20
+ hint_path.write_text(f"{project_root.resolve()}\n", encoding="utf-8")
21
+ except OSError:
22
+ # Hint persistence is convenience only; never fail gate scripts for this.
23
+ return
24
+
25
+
26
+ def read_project_root_hint(script_dir: Path) -> Path | None:
27
+ hint_path = hint_file_path(script_dir)
28
+ try:
29
+ raw = hint_path.read_text(encoding="utf-8").strip()
30
+ except OSError:
31
+ return None
32
+
33
+ if not raw:
34
+ return None
35
+
36
+ candidate = Path(raw).expanduser().resolve()
37
+ if (candidate / ".cadence").is_dir():
38
+ return candidate
39
+ return None
40
+
41
+
42
+ def find_cadence_project_root(start: Path) -> Path | None:
43
+ current = start.resolve()
44
+ for candidate in [current, *current.parents]:
45
+ if (candidate / ".cadence").is_dir():
46
+ return candidate
47
+ return None
48
+
49
+
50
+ def resolve_project_root(
51
+ *,
52
+ script_dir: Path,
53
+ explicit_project_root: str | None = None,
54
+ require_cadence: bool = False,
55
+ allow_hint: bool = True,
56
+ ) -> tuple[Path, str]:
57
+ """Resolve the active project root.
58
+
59
+ Resolution order:
60
+ 1) Explicit `--project-root`
61
+ 2) Nearest ancestor of cwd containing `.cadence`
62
+ 3) Most recent persisted hint (if enabled)
63
+ 4) cwd fallback
64
+ """
65
+
66
+ source = "cwd"
67
+ if explicit_project_root:
68
+ project_root = Path(explicit_project_root).expanduser().resolve()
69
+ source = "explicit"
70
+ else:
71
+ project_root = find_cadence_project_root(Path.cwd()) or Path.cwd().resolve()
72
+ if (project_root / ".cadence").is_dir():
73
+ source = "cwd"
74
+ elif allow_hint:
75
+ hinted_root = read_project_root_hint(script_dir)
76
+ if hinted_root is not None:
77
+ project_root = hinted_root
78
+ source = "hint"
79
+ else:
80
+ source = "cwd-fallback"
81
+ else:
82
+ source = "cwd-fallback"
83
+
84
+ if require_cadence and not (project_root / ".cadence").is_dir():
85
+ raise ValueError(f"MISSING_CADENCE_DIR: {project_root}")
86
+
87
+ if not project_root.exists():
88
+ raise ValueError(f"PROJECT_ROOT_NOT_FOUND: {project_root}")
89
+
90
+ if not project_root.is_dir():
91
+ raise ValueError(f"PROJECT_ROOT_NOT_DIRECTORY: {project_root}")
92
+
93
+ return project_root, source
@@ -1,30 +1,50 @@
1
1
  #!/usr/bin/env python3
2
2
  """Read and normalize Cadence workflow state from .cadence/cadence.json."""
3
3
 
4
+ import argparse
4
5
  import copy
5
6
  import json
6
7
  import sys
7
8
  from pathlib import Path
8
9
 
10
+ from project_root import resolve_project_root, write_project_root_hint
9
11
  from workflow_state import default_data, reconcile_workflow_state
10
12
 
11
13
 
12
- CADENCE_DIR = Path(".cadence")
13
- CADENCE_JSON_PATH = CADENCE_DIR / "cadence.json"
14
+ SCRIPT_DIR = Path(__file__).resolve().parent
14
15
 
15
16
 
16
- def load_state():
17
- cadence_exists = CADENCE_DIR.exists()
17
+ def parse_args() -> argparse.Namespace:
18
+ parser = argparse.ArgumentParser(
19
+ description="Read and normalize Cadence workflow state.",
20
+ )
21
+ parser.add_argument(
22
+ "--project-root",
23
+ default="",
24
+ help="Explicit project root path override.",
25
+ )
26
+ return parser.parse_args()
18
27
 
19
- if not CADENCE_JSON_PATH.exists():
28
+
29
+ def cadence_paths(project_root: Path) -> tuple[Path, Path]:
30
+ cadence_dir = project_root / ".cadence"
31
+ cadence_json_path = cadence_dir / "cadence.json"
32
+ return cadence_dir, cadence_json_path
33
+
34
+
35
+ def load_state(project_root: Path):
36
+ cadence_dir, cadence_json_path = cadence_paths(project_root)
37
+ cadence_exists = cadence_dir.exists()
38
+
39
+ if not cadence_json_path.exists():
20
40
  data = default_data()
21
41
  data = reconcile_workflow_state(data, cadence_dir_exists=cadence_exists)
22
42
  if cadence_exists:
23
- save_state(data)
43
+ save_state(project_root, data)
24
44
  return data
25
45
 
26
46
  try:
27
- with CADENCE_JSON_PATH.open("r", encoding="utf-8") as file:
47
+ with cadence_json_path.open("r", encoding="utf-8") as file:
28
48
  original_data = json.load(file)
29
49
  except json.JSONDecodeError as exc:
30
50
  print(f"INVALID_CADENCE_JSON: {exc}", file=sys.stderr)
@@ -32,18 +52,19 @@ def load_state():
32
52
 
33
53
  data = reconcile_workflow_state(copy.deepcopy(original_data), cadence_dir_exists=cadence_exists)
34
54
  if data != original_data:
35
- save_state(data)
55
+ save_state(project_root, data)
36
56
  return data
37
57
 
38
58
 
39
- def save_state(data):
40
- CADENCE_DIR.mkdir(parents=True, exist_ok=True)
41
- with CADENCE_JSON_PATH.open("w", encoding="utf-8") as file:
59
+ def save_state(project_root: Path, data):
60
+ cadence_dir, cadence_json_path = cadence_paths(project_root)
61
+ cadence_dir.mkdir(parents=True, exist_ok=True)
62
+ with cadence_json_path.open("w", encoding="utf-8") as file:
42
63
  json.dump(data, file, indent=4)
43
64
  file.write("\n")
44
65
 
45
66
 
46
- def build_response(data):
67
+ def build_response(data, project_root: Path, project_root_source: str):
47
68
  workflow = data.get("workflow", {})
48
69
  next_item = workflow.get("next_item", {})
49
70
  route = workflow.get("next_route", {"skill_name": "", "skill_path": "", "reason": ""})
@@ -69,12 +90,28 @@ def build_response(data):
69
90
  "next_phase": str(workflow.get("next_phase", next_item_id)),
70
91
  "route": route,
71
92
  "message": message,
93
+ "project_root": str(project_root),
94
+ "project_root_source": project_root_source,
72
95
  }
73
96
 
74
97
 
75
98
  def main():
76
- data = load_state()
77
- response = build_response(data)
99
+ args = parse_args()
100
+ explicit_project_root = args.project_root.strip() or None
101
+ try:
102
+ project_root, project_root_source = resolve_project_root(
103
+ script_dir=SCRIPT_DIR,
104
+ explicit_project_root=explicit_project_root,
105
+ require_cadence=False,
106
+ allow_hint=True,
107
+ )
108
+ except ValueError as exc:
109
+ print(str(exc), file=sys.stderr)
110
+ raise SystemExit(1)
111
+
112
+ write_project_root_hint(SCRIPT_DIR, project_root)
113
+ data = load_state(project_root)
114
+ response = build_response(data, project_root, project_root_source)
78
115
  print(json.dumps(response))
79
116
 
80
117