diff --git a/skills/workspace/workspace-planning/SKILL.md b/skills/workspace/workspace-planning/SKILL.md index bc9f9d4..d3334bc 100644 --- a/skills/workspace/workspace-planning/SKILL.md +++ b/skills/workspace/workspace-planning/SKILL.md @@ -75,10 +75,21 @@ python3 /scripts/planning.py week W3 # Show week ``` All commands output JSON for the LLM to format. Use `--file` to specify a schedule YAML -if multiple exist. +if multiple exist. Always point `--file` at the **main** schedule file (e.g. `sylsmart.yaml`), +not the month files — the script resolves `module_files` references automatically. Requires: `pip install pyyaml` +### Split vs Inline Modules + +Schedules support two layouts: + +- **Inline**: `modules:` list directly in the main YAML (simple projects) +- **Split**: `module_files:` list of relative paths, each containing a `modules:` list (large projects) + +When using split layout, all read commands (`review`, `week`) merge modules from all +referenced files. Write commands (`update`, `link`) save back to the correct source file. + ## Commands ### `planning init ` @@ -135,22 +146,33 @@ Legend: V done, * in_progress, o planned, - deferred Update a module's status. +**Step 1 — Validate** with the script (does NOT write to files): + ```bash python3 /scripts/planning.py update --status ``` -The script validates the state machine transition and returns JSON with the result. -If invalid, it shows the error with allowed target states. +The script validates the state machine transition and returns JSON including `source_file` +(the YAML file containing the module). If invalid, it exits with an error. + +**Step 2 — Apply** with the Edit tool: use the `source_file` from the JSON output to +locate the module and change its `status:` field. This preserves YAML comments and formatting. ### `planning link --change ` Associate an OpenSpec change with a module. +**Step 1 — Validate** with the script (does NOT write to files): + ```bash python3 /scripts/planning.py link --change ``` -The script verifies the change exists, appends it, and auto-transitions `planned` to `in_progress`. +The script verifies the change exists and returns JSON with the updated `changes` list, +`source_file`, and whether an auto-transition from `planned` to `in_progress` should apply. + +**Step 2 — Apply** with the Edit tool: add the change name to the module's `changes:` list +(or create the field). If `auto_transition` is true, also update `status: in_progress`. ### `planning sync-yunxiao` diff --git a/skills/workspace/workspace-planning/references/yaml-schema.md b/skills/workspace/workspace-planning/references/yaml-schema.md index db77abf..a380795 100644 --- a/skills/workspace/workspace-planning/references/yaml-schema.md +++ b/skills/workspace/workspace-planning/references/yaml-schema.md @@ -31,6 +31,7 @@ phases: # optional end: 2026-04-03 weeks: [W1, W2, W3, W4] +# Option A: inline modules modules: # Infrastructure module (backend-only, no UI frames) - id: core-extraction @@ -62,6 +63,15 @@ modules: notes: "optional notes" yunxiao_id: "WI-12345" changes: ["add-auth-api"] + +# Option B: split modules into separate files +# Use module_files instead of modules. Paths are relative to +# the main YAML file. Each referenced file has a top-level +# "modules:" list. The CLI merges them at load time and writes +# back to the correct file on update/link. +module_files: + - my-project-month-1.yaml + - my-project-month-2.yaml ``` ## Field Reference @@ -75,7 +85,7 @@ modules: | `timeline.start` | date | Project start date (ISO) | | `timeline.end` | date | Project end date (ISO) | | `milestones` | list | Milestone definitions | -| `modules` | list | Module definitions | +| `modules` | list | Module definitions (use this OR `module_files`, not both) | ### Optional Top-Level Fields @@ -83,6 +93,7 @@ modules: |-------|------|-------------| | `capacity` | object | Team capacity config | | `phases` | list | Phase definitions | +| `module_files` | list | Paths to YAML files containing modules (relative to main file; alternative to inline `modules`) | ### Module Required Fields diff --git a/skills/workspace/workspace-planning/scripts/planning.py b/skills/workspace/workspace-planning/scripts/planning.py index 66e42a2..2370bec 100644 --- a/skills/workspace/workspace-planning/scripts/planning.py +++ b/skills/workspace/workspace-planning/scripts/planning.py @@ -48,15 +48,34 @@ def find_schedule(path: str | None) -> Path: def load_schedule(path: Path) -> dict: - """Load and return the schedule YAML.""" - with open(path) as f: - return yaml.safe_load(f) - + """Load schedule YAML, resolving module_files if present. -def save_schedule(path: Path, data: dict) -> None: - """Write schedule data back to YAML, preserving readability.""" - with open(path, "w") as f: - yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) + When the main YAML contains a ``module_files`` list, modules are loaded + from each referenced file (paths relative to the main file's directory). + Each module gets a ``_source_file`` tag so callers know which file it + came from. + """ + with open(path) as f: + data = yaml.safe_load(f) + + if "module_files" in data: + all_modules: list[dict] = [] + for ref in data["module_files"]: + ref_path = path.parent / ref + if not ref_path.exists(): + print( + f"Error: referenced module file '{ref}' not found at {ref_path}", + file=sys.stderr, + ) + sys.exit(1) + with open(ref_path) as f: + ref_data = yaml.safe_load(f) + for m in ref_data.get("modules", []): + m["_source_file"] = str(ref_path) + all_modules.append(m) + data["modules"] = all_modules + + return data def find_module(data: dict, module_id: str) -> dict | None: @@ -147,7 +166,7 @@ def cmd_review(args: argparse.Namespace) -> None: def cmd_update(args: argparse.Namespace) -> None: - """Update a module's status.""" + """Validate a status transition and output what to change (does not write).""" path = find_schedule(args.file) data = load_schedule(path) @@ -172,16 +191,18 @@ def cmd_update(args: argparse.Namespace) -> None: ) sys.exit(1) - module["status"] = target - save_schedule(path, data) - - result = {"module": args.module_id, "from": current, "to": target} + result = { + "module": args.module_id, + "from": current, + "to": target, + "source_file": module.get("_source_file", str(path)), + } json.dump(result, sys.stdout, ensure_ascii=False, indent=2) print() def cmd_link(args: argparse.Namespace) -> None: - """Link an OpenSpec change to a module.""" + """Validate a change link and output what to change (does not write).""" path = find_schedule(args.file) data = load_schedule(path) @@ -201,25 +222,19 @@ def cmd_link(args: argparse.Namespace) -> None: changes = module.get("changes", []) if changes is None: changes = [] - if change_name in changes: - print(f"Warning: '{change_name}' already linked to '{args.module_id}'", file=sys.stderr) - else: - changes.append(change_name) - module["changes"] = changes - - # Auto-transition planned → in_progress - auto_transitioned = False - if module["status"] == "planned": - module["status"] = "in_progress" - auto_transitioned = True + already_linked = change_name in changes + if not already_linked: + changes = [*changes, change_name] - save_schedule(path, data) + auto_transition = module["status"] == "planned" result = { "module": args.module_id, "change": change_name, "changes": changes, - "auto_transitioned": auto_transitioned, + "already_linked": already_linked, + "auto_transition": auto_transition, + "source_file": module.get("_source_file", str(path)), } json.dump(result, sys.stdout, ensure_ascii=False, indent=2) print()