Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions skills/workspace/workspace-planning/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,21 @@ python3 <skill-dir>/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 <project-name>`
Expand Down Expand Up @@ -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 <skill-dir>/scripts/planning.py update <module-id> --status <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 <module-id> --change <change-name>`

Associate an OpenSpec change with a module.

**Step 1 — Validate** with the script (does NOT write to files):

```bash
python3 <skill-dir>/scripts/planning.py link <module-id> --change <change-name>
```

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`

Expand Down
13 changes: 12 additions & 1 deletion skills/workspace/workspace-planning/references/yaml-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -75,14 +85,15 @@ 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

| Field | Type | Description |
|-------|------|-------------|
| `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

Expand Down
69 changes: 42 additions & 27 deletions skills/workspace/workspace-planning/scripts/planning.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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()
Expand Down
Loading