diff --git a/discovery-map/INDEX.md b/discovery-map/INDEX.md index 89b49847..d7e1f688 100644 --- a/discovery-map/INDEX.md +++ b/discovery-map/INDEX.md @@ -15,7 +15,7 @@ Each phase is its own PR off the previous phase's branch (stacked PRs — see *B 3. **[Inception Process Skill](phase-03-inception-process.md)** — the conversational inception session itself (initial-session flow). **Status:** Review (PR #268) 4. **[Wire start-epic to Inception](phase-04-wire-start-epic.md)** — collapses the research/discussion menu; user-visible flip. **Status:** Review (PR #269) 5. **[Discovery Map Render](phase-05-discovery-map-render.md)** — continue-epic display + menu collapse + auto-routing. **Status:** Review (PR #270) -6. **[Refinement Session](phase-06-refinement-session.md)** — re-entry path, map editing operations, safety-by-destructiveness. **Status:** Not started +6. **[Refinement Session](phase-06-refinement-session.md)** — re-entry path, map editing operations, safety-by-destructiveness. **Status:** Review (PR #271) 7. **[Self-Healing Re-point](phase-07-self-healing.md)** — research-analysis and gap-analysis re-point to map at continue-epic boot-up. **Status:** Not started 8. **[Imports](phase-08-imports.md)** — `imports/` directory, manifest tracking, KB indexing, behaviour change for features. **Status:** Not started 9. **[Topic Splitting and Elevation](phase-09-topic-splitting-elevation.md)** — write inception items alongside; name collision validation. **Status:** Not started @@ -87,7 +87,7 @@ The merge sequence at the end of the initiative is **strictly bottom-to-top of t | `idea/inception-pr-3-process` | Phase 2 branch | Phase 3 — Inception Process Skill | [#268](https://github.com/leeovery/agentic-workflows/pull/268) | Review | | `idea/inception-pr-4-wire-start-epic` | Phase 3 branch | Phase 4 — Wire start-epic to Inception | [#269](https://github.com/leeovery/agentic-workflows/pull/269) | Review | | `idea/inception-pr-5-map-render` | Phase 4 branch | Phase 5 — Discovery Map Render | [#270](https://github.com/leeovery/agentic-workflows/pull/270) | Review | -| `idea/inception-pr-6-refinement` | Phase 5 branch | Phase 6 — Refinement Session | — | Not started | +| `idea/inception-pr-6-refinement` | Phase 5 branch | Phase 6 — Refinement Session | [#271](https://github.com/leeovery/agentic-workflows/pull/271) | Review | | `idea/inception-pr-7-self-healing` | Phase 6 branch | Phase 7 — Self-Healing Re-point | — | Not started | | `idea/inception-pr-8-imports` | Phase 7 branch | Phase 8 — Imports | — | Not started | | `idea/inception-pr-9-split-elevation` | Phase 7 branch | Phase 9 — Topic Splitting and Elevation | — | Not started | diff --git a/discovery-map/phase-07-self-healing.md b/discovery-map/phase-07-self-healing.md index 3b157f9f..d1863b34 100644 --- a/discovery-map/phase-07-self-healing.md +++ b/discovery-map/phase-07-self-healing.md @@ -61,3 +61,4 @@ Move `research-analysis` and `gap-analysis` from `workflow-discussion-entry` to - **Cache invalidation rules unchanged** — input checksum on the relevant files. Just the output target changes. - **Source deduplication** — if both analyses produce the same theme, dedupe at the analysis stage so it's added once with both source paths in the inception item's `source` field (e.g. `gap-analysis,research-analysis` or first-source-wins). - **Notification callout shows once.** When the user has seen the new items, the callout doesn't repeat on subsequent `continue-epic` boots — only when boot-up just added more. +- **`continue` resume bypasses C. Self-Healing Check** (Phase 6 left this as a no-op, so impact deferred to here). When `refinement-session.md` B. Resume Check routes the user's `continue` choice it goes straight to E. Render and Prompt — C and D are skipped because the existing session log is reused. When wiring analyses into C, decide whether continue should re-run them. Recommendation: don't — analyses ran on the prior entry, results are already on the map and recorded under the existing log's **Self-Healing Arrivals**, and re-running on resume would surface duplicate arrivals or churn the cache. The continue path effectively says "pick up the in-flight refinement as it stood"; fresh-entry / restart paths still flow through C and run normally. diff --git a/discovery-map/phase-11-migration.md b/discovery-map/phase-11-migration.md index 34ade7a0..5efef448 100644 --- a/discovery-map/phase-11-migration.md +++ b/discovery-map/phase-11-migration.md @@ -70,3 +70,4 @@ Seed the discovery map for existing in-progress epics so they continue to work u - **`migration-seeded` sub-classification** helps future analyses avoid re-proposing items the user accepted via migration. - **Hard-delete model applies** — no `cancelled` status on migrated inception items. They're all `active`. - The post-migration empty-summary walkthrough is part of refinement, not the migration script itself. Migration creates items with empty summaries; refinement detects and prompts. +- **Back-fill `session-001.md` for migrated epics.** Phase 6's refinement entry assumes `session-001.md` exists from initial inception. For legacy epics that never had inception, no such log is on disk — `refinement-session.md` D's `n=$(ls ... | wc -l)` would give `n=0`, seeding the first refinement as `session-001.md` and mis-titling it "Initial Framing". Either back-fill a placeholder `session-001.md` during migration (e.g. "Initial framing not recorded — pre-inception migration. Map seeded from existing research/discussion items.") or special-case the count in refinement when source classification is `migration-seeded`. Back-filling at migration time is the cleaner option — keeps refinement's logic simple and gives the user a discoverable record of where the map came from. diff --git a/skills/workflow-inception-entry/SKILL.md b/skills/workflow-inception-entry/SKILL.md index 124a137a..d35eea57 100644 --- a/skills/workflow-inception-entry/SKILL.md +++ b/skills/workflow-inception-entry/SKILL.md @@ -115,7 +115,9 @@ Set `source` = `first-session`. > session. ``` -Load **[validate-phase.md](references/validate-phase.md)** and follow its instructions as written. +Set `source` = `refinement` for the handoff. + +→ Proceed to **Step 4**. --- diff --git a/skills/workflow-inception-entry/references/invoke-skill.md b/skills/workflow-inception-entry/references/invoke-skill.md index d7f6f142..4434d733 100644 --- a/skills/workflow-inception-entry/references/invoke-skill.md +++ b/skills/workflow-inception-entry/references/invoke-skill.md @@ -6,6 +6,13 @@ This skill's purpose is now fulfilled. Construct the handoff and invoke the processing skill. +The `Source:` line in the handoff carries the value of `source` set earlier in the entry flow: + +- `first-session` — set in **Step 2** when no inception items exist for this work unit. +- `refinement` — set in **Step 3** when inception items already exist. + +The processing skill reads this field at Step 0 to decide whether to run the initial-session flow or open a refinement session. + --- ## Handoff @@ -13,7 +20,7 @@ This skill's purpose is now fulfilled. Construct the handoff and invoke the proc ``` Inception session for: {work_unit} -Source: first-session +Source: {source:[first-session|refinement]} Output: .workflows/{work_unit}/inception/ Description (from manifest): diff --git a/skills/workflow-inception-entry/references/validate-phase.md b/skills/workflow-inception-entry/references/validate-phase.md deleted file mode 100644 index 2c2ce764..00000000 --- a/skills/workflow-inception-entry/references/validate-phase.md +++ /dev/null @@ -1,28 +0,0 @@ -# Validate Phase - -*Reference for **[workflow-inception-entry](../SKILL.md)*** - ---- - -This is a refinement session — inception items already exist for this work unit. Refinement of the discovery map (adding, renaming, removing topics, editing summaries, changing routing) is a future-phase deliverable in this initiative. For now, surface the situation and stop. - -> *Output the next fenced block as a code block:* - -``` -●───────────────────────────────────────────────● - Inception Refinement -●───────────────────────────────────────────────● - -Refinement of the discovery map is not yet implemented. -``` - -> *Output the next fenced block as markdown (not a code block):* - -``` -> Inception items already exist for "{work_unit}". The -> refinement flow — adding, renaming, removing topics, editing -> summaries, changing routing — lands in a later phase of the -> inception/discovery-map initiative. -``` - -**STOP.** Do not proceed — terminal condition. diff --git a/skills/workflow-inception-process/SKILL.md b/skills/workflow-inception-process/SKILL.md index e7d51485..411230e8 100644 --- a/skills/workflow-inception-process/SKILL.md +++ b/skills/workflow-inception-process/SKILL.md @@ -1,7 +1,7 @@ --- name: workflow-inception-process user-invocable: false -allowed-tools: Bash(node .claude/skills/workflow-manifest/scripts/manifest.cjs) +allowed-tools: Bash(node .claude/skills/workflow-inception-process/scripts/discovery.cjs), Bash(node .claude/skills/workflow-manifest/scripts/manifest.cjs) --- # Inception Process @@ -41,91 +41,43 @@ Follow these steps EXACTLY as written. Do not skip steps or combine them. Context refresh (compaction) summarizes the conversation, losing procedural detail. When you detect a context refresh has occurred — the conversation feels abruptly shorter, you lack memory of recent steps, or a summary precedes this message — follow this recovery protocol: 1. **Re-read this skill file completely.** Do not rely on your summary of it. The full process, steps, and rules must be reloaded. -2. **Read the session log** at `.workflows/{work_unit}/inception/session-001.md` if it exists. The Topics Identified section is your primary progress indicator — it shows which topics have already been surfaced and tentatively routed. +2. **Read the most recent session log** — find the highest-numbered file matching `.workflows/{work_unit}/inception/session-*.md`. For initial sessions this is `session-001.md` and the **Topics Identified** section is your primary progress indicator. For refinement sessions (`session-NNN.md`, NNN > 1) the **Changes** section shows what has already been applied; an unfinalised log has `(none)` under **Conclusion** and should be resumed via **B. Resume Check** in `references/refinement-session.md`. 3. **Check git state.** Run `git status` and `git log --oneline -10` to see recent commits. Commit messages reveal what has been completed. -4. **Announce your position** to the user before continuing: render the current working list of topics, state what step you believe you're at, and what comes next. Wait for confirmation. +4. **Announce your position** to the user before continuing: render the current working list (initial) or the changes applied so far (refinement), state what step you believe you're at, and what comes next. Wait for confirmation. Do not guess at progress or continue from memory. The files on disk and git history are authoritative — your recollection is not. --- -## Step 0: Resume Detection +## Step 0: Source-Aware Detection > *Output the next fenced block as a code block:* ``` -── Resume Detection ───────────────────────────── +── Source-Aware Detection ─────────────────────── ``` > *Output the next fenced block as markdown (not a code block):* ``` -> Checking for an existing inception session log on disk. The -> entry skill has already verified there are no inception items -> in the manifest — this check is purely about within-session -> recovery after a context refresh. +> Reading the handoff source. Refinement re-entry routes +> straight to the refinement flow; first-session entry checks +> for an interrupted draft session log on disk. ``` -Check whether any file matching `.workflows/{work_unit}/inception/session-*.md` exists. +Read the `Source:` field from the handoff in the prior message. -#### If no file exists +#### If `source` is `refinement` -→ Proceed to **Step 1**. +Inception items already exist for this work unit. Open the refinement session. -#### If `session-001.md` is the only session file +Load **[refinement-session.md](references/refinement-session.md)** and follow its instructions as written. -A prior in-session draft is on disk and the session was interrupted (likely a context refresh). Read the file, then offer continue or restart. +#### If `source` is `first-session` -> *Output the next fenced block as markdown (not a code block):* - -``` -Found an in-progress inception session log for **{work_unit:(titlecase)}**. - -· · · · · · · · · · · · -- **`c`/`continue`** — Pick up where you left off -- **`r`/`restart`** — Delete the draft session log and start fresh -· · · · · · · · · · · · -``` - -**STOP.** Wait for user response. - -#### If `continue` - -→ Proceed to **Step 2**. The draft session log is your working list — session-loop will brief the user on resume. - -#### If `restart` - -1. Delete the draft session log. -2. Commit: `inception({work_unit}): restart inception session`. - -→ Proceed to **Step 1**. - - - -#### If any `session-NNN.md` for N > 1 exists - -The work unit has previously concluded an inception session and is being re-entered. Refinement is a future-phase deliverable in this initiative. - -> *Output the next fenced block as a code block:* - -``` -●───────────────────────────────────────────────● - Inception Refinement -●───────────────────────────────────────────────● - -Refinement of the discovery map is not yet implemented. -``` - -> *Output the next fenced block as markdown (not a code block):* - -``` -> A prior inception session has concluded for "{work_unit}". The -> refinement flow — adding, renaming, removing topics, editing -> summaries, changing routing — lands in a later phase of the -> inception/discovery-map initiative. -``` +The entry skill has verified there are no inception items in the manifest. Check the inception directory for an interrupted draft before starting fresh. -**STOP.** Do not proceed — terminal condition. +Load **[first-session-resume.md](references/first-session-resume.md)** and follow its instructions as written. --- diff --git a/skills/workflow-inception-process/references/first-session-resume.md b/skills/workflow-inception-process/references/first-session-resume.md new file mode 100644 index 00000000..e92180ed --- /dev/null +++ b/skills/workflow-inception-process/references/first-session-resume.md @@ -0,0 +1,77 @@ +# First-Session Resume Detection + +*Reference for **[workflow-inception-process](../SKILL.md)*** + +--- + +The entry skill has already verified that no inception items exist in the manifest, so this is a first-session entry. This reference recovers from a context refresh that may have interrupted a prior first-session draft by checking the inception directory for session log files. + +## A. Detect Prior Session + +Check whether any file matching `.workflows/{work_unit}/inception/session-*.md` exists. + +#### If no file matches + +No prior draft on disk — start fresh. + +→ Return to **[the skill](../SKILL.md)** for **Step 1**. + +#### If `session-001.md` is the only matching file + +A prior in-session draft is on disk and the session was interrupted (likely a context refresh). Read the file, then offer continue or restart: + +> *Output the next fenced block as markdown (not a code block):* + +``` +Found an in-progress inception session log for **{work_unit:(titlecase)}**. + +· · · · · · · · · · · · +- **`c`/`continue`** — Pick up where you left off +- **`r`/`restart`** — Delete the draft session log and start fresh +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +**If `continue`:** + +The draft session log is your working list — `session-loop.md` will brief the user on resume. + +→ Return to **[the skill](../SKILL.md)** for **Step 2**. + +**If `restart`:** + +Delete the draft session log and commit: + +```bash +rm .workflows/{work_unit}/inception/session-001.md +git add -- .workflows/{work_unit}/ +git commit -m "inception({work_unit}): restart inception session" +``` + +→ Return to **[the skill](../SKILL.md)** for **Step 1**. + +#### If any `session-NNN.md` for N > 1 exists + +Defensive guard. The entry skill should have routed `source = refinement` when prior session logs and inception items both exist, but the handoff said `first-session`. State is inconsistent — likely the inception items were removed from the manifest while the session logs were retained. + +> *Output the next fenced block as a code block:* + +``` +●───────────────────────────────────────────────● + Inception — Inconsistent State +●───────────────────────────────────────────────● + +Prior inception session logs exist but the manifest reports no +inception items for "{work_unit}". +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +> Stopping here so you can reconcile. Either restore the +> manifest items (refinement re-entry) or archive the session +> logs out of the way (fresh first session). +``` + +**STOP.** Do not proceed — terminal condition. diff --git a/skills/workflow-inception-process/references/map-operations.md b/skills/workflow-inception-process/references/map-operations.md new file mode 100644 index 00000000..4b509940 --- /dev/null +++ b/skills/workflow-inception-process/references/map-operations.md @@ -0,0 +1,427 @@ +# Map Operations + +*Reference for **[workflow-inception-process](../SKILL.md)*** + +--- + +Per-operation handling for refinement. Owns parsing, validation, manifest writes, session-log entries, and commits. Loaded by **[refinement-session.md](refinement-session.md)** when the user names one or more changes. + +The parent reference owns the conversation shape; this file owns the writes. State for validation comes from `skills/workflow-inception-process/scripts/discovery.cjs` — invoke it via Bash and read the structured output. Never invoke the underlying Node helpers inline. + +After all of the user's operations have been processed, return to caller. + +## A. Parse Operations + +Re-run discovery to pick up state changes since the last invocation (operations applied earlier in the session, or the parent's initial discovery): + +```bash +node .claude/skills/workflow-inception-process/scripts/discovery.cjs {work_unit} +``` + +Read `discovery_map` (per-topic `tier`, `lifecycle`, `routing`, `summary`, `source`) and `dismissed`. These drive validation in **B**. + +Then read the user's most recent message. Extract one or more operations. Recognised intents: + +| User phrasing | Operation | Required values | +| ----------------------------------------------- | --------------- | ---------------------------------------- | +| *"add X as research"*, *"add Y as discussion"* | Add | name, routing | +| *"edit summary of X to Y"*, *"reword X's blurb"*| Edit summary | name, new summary | +| *"remove X"*, *"drop X"*, *"delete X"* | Remove | name | +| *"rename X to Y"* | Rename | old name, new name | +| *"change routing of X to discussion"* | Change routing | name, new routing | + +If routing is omitted on Add, infer from cues in the user's framing (factual unknowns → research; opinion or design → discussion). The proposal is tentative — the STOP gate is where the user flips it. + +If the message is ambiguous (e.g. *"fix X"*, *"that one looks wrong"*), ask one clarifying question before proceeding. No STOP gate is needed for clarification — it's part of conversational flow, not a manifest write. + +**Group operations** for safety-by-destructiveness: + +- **Additive group** — a contiguous run of Add operations *or* a contiguous run of Edit summary operations. Each group batches into one STOP gate, one commit, one session-log entry. +- **Destructive group** — a single Remove, Rename, or Change routing operation. Each is its own group of one with its own STOP gate and commit. + +Walk the groups in user order. For mixed batches (e.g. *"remove A, rename B to B2, add C"*), each destructive op is its own group; contiguous additive ops in between batch. + +→ Proceed to **B. Validate**. + +## B. Validate + +Apply per-operation validation gates **before** any STOP gate. If validation fails for an operation, surface the rejection with a clear next-step pointer (don't just say "blocked") and remove the operation from its group. Continue with the rest. + +**Lifecycle gates** — for destructive operations (Remove, Rename, Change routing), look up the operation's target topic in `discovery_map` and read its `lifecycle` field. The operation is allowed only when: + +| Operation | Allowed lifecycles | Disallowed | +| --------------- | ------------------ | --------------------------------------------------------------------------- | +| Remove | `fresh` | `researching`, `discussing`, `ready_for_discussion`, `decided`, `cancelled` | +| Rename | `fresh` | all others | +| Change routing | `fresh` | all others (routing is implicit once a phase item exists) | +| Edit summary | any | — | +| Add | n/a (new item) | — | + +`cancelled` is also disallowed for Remove because the inception item is the historical record of the topic ever having existed. Removal is for never-started topics only; cancel-then-vanish would erase the audit trail. The `a`/`cancel` flow in `/continue-epic` is the right tool for stopping in-flight work. + +Render the rejection in a code block: + +> *Output the next fenced block as a code block:* + +``` +"{topic}" can't be {removed|renamed|re-routed} from the map — +{lifecycle_phrase}. To stop work on it, use `a`/`cancel` in +/continue-epic instead. +``` + +`{lifecycle_phrase}` examples: + +- `researching` — `research is in flight on it` +- `discussing` — `discussion is in flight on it` +- `ready_for_discussion` — `research has completed and discussion is queued` +- `decided` — `discussion has concluded` +- `cancelled` — `it has phase work in cancelled state and stays on the map as historical record` + +**Name collision gates** — for Add and Rename, check the new name against `discovery_map`'s topic names (case-sensitive). A match means an **active** map item already uses the name — reject: + +> *Output the next fenced block as a code block:* + +``` +"{name}" is already on the map. Pick a different name or use +edit-summary / change-routing on the existing item. +``` + +For Add, a name appearing in `dismissed` is **allowed** — it counts as a re-add. The Add flow pulls the name from the dismissed list before creating the new item. + +→ Proceed to **C. Apply**. + +## C. Apply + +Walk the validated operation groups in user order. For the next pending group: + +#### If the group is one or more Add operations + +→ Proceed to **D. Add**. + +#### If the group is one or more Edit summary operations + +→ Proceed to **E. Edit Summary**. + +#### If the group is a Remove operation + +→ Proceed to **F. Remove**. + +#### If the group is a Rename operation + +→ Proceed to **G. Rename**. + +#### If the group is a Change routing operation + +→ Proceed to **H. Change Routing**. + +#### Otherwise (no groups remain) + +→ Proceed to **I. Done**. + +## D. Add + +Render the proposal once for the whole batch: + +> *Output the next fenced block as a code block:* + +``` +Adding {N} topic(s): + + • {name_1} (routing: {research|discussion}, source: inception) + • {name_2} (routing: {research|discussion}, source: inception) + ... +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Add all? + +- **`y`/`yes`** +- **`n`/`no`** +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `no` + +Skip the batch. No manifest writes, no session-log entry, no commit. + +→ Return to **C. Apply** for the next group. + +#### If `yes` + +For each name in the batch: + +1. If the name appears in `dismissed` (from the discovery output read in **A**), pull it: + + ```bash + node .claude/skills/workflow-manifest/scripts/manifest.cjs pull {work_unit}.inception dismissed "{name}" + ``` + +2. Initialise the inception item and set its fields: + + ```bash + node .claude/skills/workflow-manifest/scripts/manifest.cjs init-phase {work_unit}.inception.{name} + node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{name} summary "{one-line summary}" + node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{name} routing {research|discussion} + node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{name} source inception + ``` + + Source is `inception` for refinement-added topics — they are user-curated, indistinguishable from initial-session items for provenance purposes. + +3. Append a single batch entry to the session log under **Changes** (one bullet per name). If the section currently reads `(none)`, replace it with the bullets: + + ```markdown + - Added: {name_1} (routing: {research|discussion}, source: inception) — {short rationale} + - Added: {name_2} (routing: {research|discussion}, source: inception) — {short rationale} + ``` + +4. Single commit covering all adds in the batch: + + ```bash + git add -- .workflows/{work_unit}/manifest.json .workflows/{work_unit}/inception/session-{NNN}.md + git commit -m "inception({work_unit}): add {N} topic(s) to map" + ``` + +→ Return to **C. Apply** for the next group. + +## E. Edit Summary + +Render the proposal once for the whole batch: + +> *Output the next fenced block as a code block:* + +``` +Updating {N} summary(ies): + + • {name_1}: "{new summary}" + • {name_2}: "{new summary}" + ... +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Apply? + +- **`y`/`yes`** +- **`n`/`no`** +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `no` + +Skip the batch. No manifest writes, no session-log entry, no commit. + +→ Return to **C. Apply** for the next group. + +#### If `yes` + +For each: + +```bash +node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{name} summary "{new summary}" +``` + +Append a single batch entry to the session log under **Changes**. If the section currently reads `(none)`, replace it with the bullets: + +```markdown +- Edited summary: {name_1} — {short note} +- Edited summary: {name_2} — {short note} +``` + +Single commit: + +```bash +git add -- .workflows/{work_unit}/manifest.json .workflows/{work_unit}/inception/session-{NNN}.md +git commit -m "inception({work_unit}): edit {N} summary(ies)" +``` + +→ Return to **C. Apply** for the next group. + +## F. Remove + +Render the proposal: + +> *Output the next fenced block as a code block:* + +``` +Remove "{name}" from the map. + + Lifecycle: fresh — no work has started on this topic. + The name will be added to the dismissed list so analyses + won't auto-re-propose it. +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Confirm removal? + +- **`y`/`yes`** +- **`n`/`no`** +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `no` + +Skip this operation. No manifest writes, no session-log entry, no commit. + +→ Return to **C. Apply** for the next group. + +#### If `yes` + +Hard-delete the inception item and add the name to the dismissed list: + +```bash +node .claude/skills/workflow-manifest/scripts/manifest.cjs delete {work_unit}.inception items.{name} +node .claude/skills/workflow-manifest/scripts/manifest.cjs push {work_unit}.inception dismissed "{name}" +``` + +Append a Changes entry to the session log. If the section currently reads `(none)`, replace it with the bullet: + +```markdown +- Removed: {name} — {short reason} +``` + +Per-item commit: + +```bash +git add -- .workflows/{work_unit}/manifest.json .workflows/{work_unit}/inception/session-{NNN}.md +git commit -m "inception({work_unit}): remove {name} from map" +``` + +→ Return to **C. Apply** for the next group. + +## G. Rename + +Render the proposal: + +> *Output the next fenced block as a code block:* + +``` +Rename "{old}" → "{new}". + + Lifecycle: fresh — no work has started, no files exist + under this name. Manifest mutation only. +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Confirm rename? + +- **`y`/`yes`** +- **`n`/`no`** +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `no` + +Skip this operation. No manifest writes, no session-log entry, no commit. + +→ Return to **C. Apply** for the next group. + +#### If `yes` + +Read the existing fields, delete the old key, create the new key, re-write the fields: + +```bash +saved_summary=$(node .claude/skills/workflow-manifest/scripts/manifest.cjs get {work_unit}.inception.{old} summary) +saved_routing=$(node .claude/skills/workflow-manifest/scripts/manifest.cjs get {work_unit}.inception.{old} routing) +saved_source=$(node .claude/skills/workflow-manifest/scripts/manifest.cjs get {work_unit}.inception.{old} source) + +node .claude/skills/workflow-manifest/scripts/manifest.cjs delete {work_unit}.inception items.{old} +node .claude/skills/workflow-manifest/scripts/manifest.cjs init-phase {work_unit}.inception.{new} +node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{new} summary "$saved_summary" +node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{new} routing "$saved_routing" +node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{new} source "$saved_source" +``` + +If any command fails, surface the error and stop before the commit so the user can recover — a partial rename leaves the manifest in an inconsistent state otherwise. + +Append a Changes entry to the session log. If the section currently reads `(none)`, replace it with the bullet: + +```markdown +- Renamed: {old} → {new} — {short reason} +``` + +Per-item commit: + +```bash +git add -- .workflows/{work_unit}/manifest.json .workflows/{work_unit}/inception/session-{NNN}.md +git commit -m "inception({work_unit}): rename {old} → {new}" +``` + +→ Return to **C. Apply** for the next group. + +## H. Change Routing + +Render the proposal: + +> *Output the next fenced block as a code block:* + +``` +Change routing of "{name}": {old routing} → {new routing}. + + Lifecycle: fresh — no phase work yet, so the routing + hint is mutable. +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Confirm routing change? + +- **`y`/`yes`** +- **`n`/`no`** +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `no` + +Skip this operation. No manifest writes, no session-log entry, no commit. + +→ Return to **C. Apply** for the next group. + +#### If `yes` + +```bash +node .claude/skills/workflow-manifest/scripts/manifest.cjs set {work_unit}.inception.{name} routing {research|discussion} +``` + +Append a Changes entry to the session log. If the section currently reads `(none)`, replace it with the bullet: + +```markdown +- Changed routing: {name} → {new routing} — {short reason} +``` + +Per-item commit: + +```bash +git add -- .workflows/{work_unit}/manifest.json .workflows/{work_unit}/inception/session-{NNN}.md +git commit -m "inception({work_unit}): re-route {name} to {new routing}" +``` + +→ Return to **C. Apply** for the next group. + +## I. Done + +All operation groups have been processed. + +→ Return to caller. diff --git a/skills/workflow-inception-process/references/refinement-session.md b/skills/workflow-inception-process/references/refinement-session.md new file mode 100644 index 00000000..ae5f2a8a --- /dev/null +++ b/skills/workflow-inception-process/references/refinement-session.md @@ -0,0 +1,296 @@ +# Refinement Session + +*Reference for **[workflow-inception-process](../SKILL.md)*** + +--- + +This reference drives the re-entry path into inception. Items already exist on the discovery map; the user's intent is to refine — add, edit, remove, rename, or re-route topics. + +The convention is conversational, not menu-driven. STOP gates wrap manifest writes, scaled to destructiveness — additive operations batch, destructive operations are per-item. The map-operations reference owns parsing, validation, and persistence; this file owns the conversation shape. + +State for this reference comes from the discovery script at `skills/workflow-inception-process/scripts/discovery.cjs`. Sections invoke it via Bash and read the structured output — they never invoke the underlying Node helpers inline. + +Two anti-patterns to avoid: + +- **Do not call `knowledge index`.** Inception session logs (initial or refinement) are journey records, not retrievable artifacts. +- **Do not set a phase-level `status: completed`.** Inception remains alive as long as the work unit is in-progress. + +## A. Read State + +> *Output the next fenced block as markdown (not a code block):* + +``` +> Loading the current discovery map, dismissed list, and the latest +> session log status. +``` + +Run discovery for the work unit: + +```bash +node .claude/skills/workflow-inception-process/scripts/discovery.cjs {work_unit} +``` + +The output drives the rest of this file: + +- **`map_summary`** — `{total} topics — ...` line. Used in **D** (session log header) and **E** (render). +- **`discovery_map`** — per-topic `tier`, `lifecycle`, `current_phase`, `routing`, `source`, `summary`. Used in **E** (render). +- **`dismissed`** — names of topics previously removed via refinement. Used by `show-dismissed.md`. +- **`latest_session`** — `{filename, number, is_refinement, is_in_progress, conclusion_text, relative_path}`. Used in **B** (resume detection). +- **`next_session_number`** — zero-padded next session number to seed in **D**. + +→ Proceed to **B. Resume Check**. + +## B. Resume Check + +Read `latest_session` and `next_session_number` from the discovery output produced in **A**. + +#### If `latest_session` is null or `latest_session.is_refinement` is `false` + +No refinement is in flight (only the initial session log exists, or no logs at all). + +→ Proceed to **C. Self-Healing Check**. + +#### If `latest_session.is_refinement` is `true` and `latest_session.is_in_progress` is `false` + +The prior refinement concluded normally. Treat this as a fresh entry. + +→ Proceed to **C. Self-Healing Check**. + +#### If `latest_session.is_refinement` is `true` and `latest_session.is_in_progress` is `true` + +The prior refinement was interrupted (Conclusion is `(none)`). Offer continue or restart: + +> *Output the next fenced block as markdown (not a code block):* + +``` +Found an in-progress refinement session log for **{work_unit:(titlecase)}**: `{latest_session.filename}`. + +· · · · · · · · · · · · +- **`c`/`continue`** — Pick up where you left off +- **`r`/`restart`** — Delete the draft refinement log and start fresh +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +**If `continue`:** + +The active session log is `{latest_session.filename}`. No new log is initialised; subsequent operations append to the existing log. + +→ Proceed to **E. Render and Prompt**. + +**If `restart`:** + +Delete the in-progress log and commit: + +```bash +rm {latest_session.relative_path} +git add -- .workflows/{work_unit}/ +git commit -m "inception({work_unit}): restart refinement session" +``` + +→ Proceed to **C. Self-Healing Check**. + +## C. Self-Healing Check + +> *Output the next fenced block as markdown (not a code block):* + +``` +> Phase 6 placeholder — self-healing analyses (research-analysis, +> gap-analysis) are wired in Phase 7. Nothing runs here yet. +``` + +No-op for Phase 6. Phase 7 will run the analyses inline at this point and apply results to the map (auto-add with `source: research-analysis` or `source: gap-analysis`, filtered against `dismissed`). Any items added by analyses will be recorded under **Self-Healing Arrivals** in the session log initialised in **D**. + +→ Proceed to **D. Initialise Session Log**. + +## D. Initialise Session Log + +Re-run discovery to pick up any state changes from a `restart` in **B**: + +```bash +node .claude/skills/workflow-inception-process/scripts/discovery.cjs {work_unit} +``` + +Read `next_session_number` and `map_summary` from the output. The new session log path is `.workflows/{work_unit}/inception/session-{next_session_number}.md`. + +Create the file from **[refinement-template.md](refinement-template.md)**. Populate the header (date, work unit) and **Map State at Start** with the `map_summary` text. Leave **Self-Healing Arrivals**, **Changes**, and **Conclusion** as `(none)` placeholders — they fill in as operations are applied and at finalisation. The `(none)` Conclusion is the resume-detection signal used by **B**. + +Commit: + +```bash +git add -- .workflows/{work_unit}/inception/session-{next_session_number}.md +git commit -m "inception({work_unit}): seed refinement session log" +``` + +→ Proceed to **E. Render and Prompt**. + +## E. Render and Prompt + +> *Output the next fenced block as markdown (not a code block):* + +``` +> Refining the discovery map. Tell me what to change — add, +> edit, remove, rename, or re-route topics. Multiple changes in +> one message are fine; I'll work through them. +``` + +Render the current map as a status-display anchor, using `discovery_map` and `map_summary` from **A** (or from the resumed log's matching state if **B** routed `continue`): + +> *Output the next fenced block as a code block:* + +``` +●───────────────────────────────────────────────● + Refinement — {work_unit:(titlecase)} +●───────────────────────────────────────────────● + + Discovery Map ({summary_line}) + +@foreach(topic in discovery_map) + @if(not last_topic) ├─ @else └─ @endif {topic.tier} {topic.name:(titlecase)} {lifecycle_label} +@endforeach +``` + +**Render rules** (subset of continue-epic's discovery map block): + +- `summary_line`: `{total} topics — {decided} decided · {in_flight} in flight · {ready} ready · {fresh} fresh · {cancelled} cancelled`. Omit zero-count categories from the dot-separated tail. Always include `{total} topics`. +- Tier and ordering — discovery output is already tier-sorted (`→ ◐ ✓ ○ ⊘`, alphabetical within tier). Render in the order given. +- `lifecycle_label` by tier: + - `→` — `research complete · ready for discussion` + - `◐` — `researching` or `discussing` (use `topic.current_phase`) + - `✓` — `decided` + - `○` — `fresh · routed to {topic.routing}` (omit ` · routed to ...` if `topic.routing` is null) + - `⊘` — `cancelled` +- No source provenance sub-line, no key block, no menu — this is an anchor, not the continue-epic display. + +Then prompt the user: + +> *Output the next fenced block as a code block:* + +``` +What would you like to change? +``` + +**STOP.** Wait for user response. + +→ Proceed to **F. Operations Loop**. + +## F. Operations Loop + +The user's most recent message names one or more changes in natural language, asks to see dismissed items, or signals they are done. + +#### If the user's message is a request to see dismissed items + +Triggers include *"show dismissed"*, *"what was removed"*, *"let me see what I dropped"*. + +→ Load **[show-dismissed.md](show-dismissed.md)** and follow its instructions as written. + +When it returns: + +→ Proceed to **G. Anything Else?**. + +#### If the user's message signals they are done + +Triggers include *"no"*, *"done"*, *"that's it"*, *"all good"*, *"wrap up"*. + +→ Proceed to **H. Finalise Session Log**. + +#### Otherwise + +The message names operations. + +→ Load **[map-operations.md](map-operations.md)** and follow its instructions as written. + +`map-operations.md` re-runs discovery for fresh state, parses, validates, applies safety-by-destructiveness gating, writes the manifest, appends to the active session log, and commits per its own pattern. When it returns: + +→ Proceed to **G. Anything Else?**. + +## G. Anything Else? + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Anything else to change? + +- **Tell me what's next** — Name more changes (or "show dismissed") +- **`d`/`done`** — Conclude refinement and return to the epic menu +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `done` + +→ Proceed to **H. Finalise Session Log**. + +#### Otherwise + +→ Return to **F. Operations Loop**. + +## H. Finalise Session Log + +Re-run discovery to pick up the post-operations state: + +```bash +node .claude/skills/workflow-inception-process/scripts/discovery.cjs {work_unit} +``` + +Read `map_summary.total` from the output. Replace the `(none)` placeholder in the **Conclusion** section of the active session log. The replacement is non-optional — leaving `(none)` would make the log indistinguishable from an interrupted session on the next refinement entry. + +#### If at least one operation was applied during the session + +Replace `(none)` with: `{N} changes applied. Map now has {map_summary.total} topics.` + +→ Proceed to **I. Compliance Self-Check**. + +#### Otherwise (browse-only refinement, no operations applied) + +Replace `(none)` with: `No changes applied — browse only. Map has {map_summary.total} topics.` + +→ Proceed to **I. Compliance Self-Check**. + +## I. Compliance Self-Check + +→ Load **[compliance-check.md](../../workflow-shared/references/compliance-check.md)** and follow its instructions as written. + +The check audits the refinement against this file, the parent SKILL.md, and any other references loaded during the session (`map-operations.md`, `show-dismissed.md`, `refinement-template.md`). Apply silent corrections inline; surface significant issues per the shared protocol. + +When it returns: + +→ Proceed to **J. Final Sweep**. + +## J. Final Sweep + +Check `git status`. + +#### If the working tree is dirty + +```bash +git add -- .workflows/{work_unit}/ +git commit -m "inception({work_unit}): finalise refinement session log" +``` + +→ Proceed to **K. Bridge**. + +#### If the working tree is clean + +→ Proceed to **K. Bridge**. + +## K. Bridge + +> *Output the next fenced block as markdown (not a code block):* + +``` +> Refinement complete. Returning to the epic menu so you can +> pick the next move from the updated map. +``` + +``` +Pipeline bridge for: {work_unit} +Completed phase: inception + +Invoke the workflow-bridge skill to enter plan mode with continuation instructions. +``` + +**STOP.** Do not proceed — terminal condition. diff --git a/skills/workflow-inception-process/references/refinement-template.md b/skills/workflow-inception-process/references/refinement-template.md new file mode 100644 index 00000000..0976a5c5 --- /dev/null +++ b/skills/workflow-inception-process/references/refinement-template.md @@ -0,0 +1,55 @@ +# Refinement Session Log Template + +*Reference for **[workflow-inception-process](../SKILL.md)*** + +--- + +Structure for `.workflows/{work_unit}/inception/session-{NNN}.md` where `NNN` is the next zero-padded sequence number after the existing session logs (initial = `001`, first refinement = `002`, etc.). + +Keep all section headings — write `(none)` under any that have no content rather than removing the section. The empty section is a positive signal it was considered, not missed. The log is brief, rationale-focused, and keyed by event. + +## Template + +```markdown +# Inception Session {NNN} — Refinement + +Date: {YYYY-MM-DD} +Work unit: {work_unit} + +## Map State at Start + +{One-line summary: total topics and counts by lifecycle.} +Example: `8 topics — 2 decided · 3 in flight · 1 ready · 2 fresh` + +## Self-Healing Arrivals + +(none) + +## Changes + +(none) + +## Conclusion + +(none) +``` + +## Initialisation vs finalisation + +The template is written to disk **at session start** with `(none)` placeholders under **Self-Healing Arrivals**, **Changes**, and **Conclusion**. The header and **Map State at Start** are populated immediately. + +- **Self-Healing Arrivals** — Phase 6 leaves `(none)`. Phase 7 will replace it with the items added by analyses (one bullet each: `- {topic} (added by {analysis}, source: {provenance})`) or leave `(none)` if no analyses ran. +- **Changes** — when the first operation is applied, the `(none)` placeholder is replaced with the operation bullet(s). Subsequent operations append. +- **Conclusion** — the `(none)` placeholder is replaced **only at finalisation** (after the operations loop ends). The replacement is one of: + - `{N} changes applied. Map now has {M} topics.` — when one or more changes were applied. + - `No changes applied — browse only. Map has {M} topics.` — when the user opened refinement, looked, and exited without changes. + +The `(none)` Conclusion is the **resume-detection signal**: if a later refinement entry finds a session log whose Conclusion is still `(none)`, that session was interrupted (context refresh, user exit) before finalisation. Always replace it at finalisation so the next entry sees a clean state. + +## Anti-patterns + +- No transcript-style content. The log is rationale, not dialogue. +- No decisions, options, or trade-offs. That belongs in discussion. +- No investigation. The log records what changed, not what was uncovered. + +→ Return to caller. diff --git a/skills/workflow-inception-process/references/show-dismissed.md b/skills/workflow-inception-process/references/show-dismissed.md new file mode 100644 index 00000000..29d5ac32 --- /dev/null +++ b/skills/workflow-inception-process/references/show-dismissed.md @@ -0,0 +1,76 @@ +# Show Dismissed + +*Reference for **[workflow-inception-process](../SKILL.md)*** + +--- + +Recovery flow for the refinement session. Surfaces topic names previously removed from the map and offers re-add. Loaded by **[refinement-session.md](refinement-session.md)** when the user asks to see dismissed items. + +State comes from `skills/workflow-inception-process/scripts/discovery.cjs` — invoke it via Bash and read the structured output. Never invoke the underlying Node helpers inline. + +## A. Read Dismissed List + +Re-run discovery to pick up any state changes since the parent's initial discovery (a Remove earlier in the session may have added a new entry): + +```bash +node .claude/skills/workflow-inception-process/scripts/discovery.cjs {work_unit} +``` + +Read the `dismissed` array from the output. + +#### If `dismissed` is empty + +> *Output the next fenced block as a code block:* + +``` +Dismissed Topics + + (none) +``` + +→ Return to caller. + +#### Otherwise + +→ Proceed to **B. Render and Prompt**. + +## B. Render and Prompt + +> *Output the next fenced block as a code block:* + +``` +Dismissed Topics + +@foreach(name in dismissed) + • {name} +@endforeach +``` + +> *Output the next fenced block as markdown (not a code block):* + +``` +· · · · · · · · · · · · +Re-add any of these to the map? + +- **Name them** — Tell me which to re-add (and routing if known) +- **`b`/`back`** — Return to the refinement session +· · · · · · · · · · · · +``` + +**STOP.** Wait for user response. + +#### If `back` + +→ Return to caller. + +#### If the user names one or more dismissed items to re-add + +Treat the response as an Add intent and dispatch to the Add flow: + +→ Load **[map-operations.md](map-operations.md)** and follow its instructions as written. + +`map-operations.md` re-runs discovery, validates each name (collision check is satisfied — dismissed-list match is allowed for Add and triggers a `pull` from the dismissed list before `init-phase`), STOP-gates on the batch, applies the writes, appends a Changes entry to the session log, and commits. + +When `map-operations.md` returns: + +→ Return to caller. diff --git a/skills/workflow-inception-process/scripts/discovery.cjs b/skills/workflow-inception-process/scripts/discovery.cjs new file mode 100644 index 00000000..e13b33dc --- /dev/null +++ b/skills/workflow-inception-process/scripts/discovery.cjs @@ -0,0 +1,168 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + loadManifest, + phaseItems, + computeTopicLifecycle, + computeMapSummary, + computeSourceProvenance, + TIER_RANK, +} = require('../../workflow-shared/scripts/discovery-utils.cjs'); + +function buildDiscoveryMap(manifest) { + const inceptionItems = phaseItems(manifest, 'inception'); + if (inceptionItems.length === 0) return { map: [], summary: { total: 0, decided: 0, in_flight: 0, ready: 0, fresh: 0, cancelled: 0 } }; + const map = inceptionItems.map(item => { + const { lifecycle, tier, current_phase } = computeTopicLifecycle(manifest, item.name); + return { + name: item.name, + summary: item.summary || null, + routing: item.routing || null, + source: item.source || 'inception', + source_provenance: computeSourceProvenance(item.source), + lifecycle, + tier, + current_phase, + }; + }); + map.sort((a, b) => { + const ra = TIER_RANK[a.tier] != null ? TIER_RANK[a.tier] : 99; + const rb = TIER_RANK[b.tier] != null ? TIER_RANK[b.tier] : 99; + if (ra !== rb) return ra - rb; + return a.name.localeCompare(b.name); + }); + return { map, summary: computeMapSummary(map) }; +} + +function findLatestSessionLog(cwd, workUnit) { + const dir = path.join(cwd, '.workflows', workUnit, 'inception'); + let files; + try { + files = fs.readdirSync(dir).filter(f => /^session-\d+\.md$/.test(f)).sort(); + } catch { + return null; + } + if (files.length === 0) return null; + const filename = files[files.length - 1]; + const fullPath = path.join(dir, filename); + let content; + try { + content = fs.readFileSync(fullPath, 'utf8'); + } catch { + return null; + } + const m = filename.match(/^session-(\d+)\.md$/); + const number = parseInt(m[1], 10); + + // Detect Conclusion section status (placeholder = "(none)" on the first + // non-empty line after the heading). An in-progress refinement log is the + // resume signal used by refinement-session.md B. Resume Check. + let conclusionText = ''; + const conclusionMatch = content.match(/##\s+Conclusion\s*\n([\s\S]*?)(?:\n##\s|$)/); + if (conclusionMatch) { + const body = conclusionMatch[1].trim(); + conclusionText = body.split('\n')[0].trim(); + } + const isInProgress = conclusionText === '(none)'; + + return { + filename, + relative_path: path.posix.join('.workflows', workUnit, 'inception', filename), + number, + is_refinement: number > 1, + is_in_progress: isInProgress, + conclusion_text: conclusionText, + }; +} + +function discover(cwd, workUnit) { + const manifest = loadManifest(cwd, workUnit); + if (!manifest) { + return { error: `Work unit "${workUnit}" not found` }; + } + const inceptionPhase = (manifest.phases || {}).inception || {}; + const dismissed = Array.isArray(inceptionPhase.dismissed) ? inceptionPhase.dismissed.slice() : []; + const { map, summary } = buildDiscoveryMap(manifest); + const latestSession = findLatestSessionLog(cwd, workUnit); + const nextSessionNumber = latestSession ? latestSession.number + 1 : 1; + return { + work_unit: workUnit, + discovery_map: map, + map_summary: summary, + dismissed, + latest_session: latestSession, + next_session_number: nextSessionNumber, + }; +} + +function format(result) { + if (result.error) { + return `error: ${result.error}\n`; + } + const lines = []; + lines.push(`=== INCEPTION DISCOVERY: ${result.work_unit} ===`); + + const s = result.map_summary; + lines.push(`map_summary: ${s.total} topics — ${s.decided} decided, ${s.in_flight} in-flight, ${s.ready} ready, ${s.fresh} fresh, ${s.cancelled} cancelled`); + lines.push(''); + + lines.push(`discovery_map (${result.discovery_map.length}):`); + if (result.discovery_map.length === 0) { + lines.push(' (empty)'); + } else { + for (const t of result.discovery_map) { + let line = ` - ${t.tier} ${t.name} [${t.lifecycle}]`; + if (t.routing) line += ` routing=${t.routing}`; + if (t.source && t.source !== 'inception') line += ` source=${t.source}`; + if (t.current_phase) line += ` phase=${t.current_phase}`; + if (t.summary) line += ` — ${t.summary}`; + lines.push(line); + } + } + lines.push(''); + + lines.push(`dismissed (${result.dismissed.length}):`); + if (result.dismissed.length === 0) { + lines.push(' (none)'); + } else { + for (const name of result.dismissed) { + lines.push(` - ${name}`); + } + } + lines.push(''); + + lines.push('latest_session:'); + if (!result.latest_session) { + lines.push(' (no session logs on disk)'); + } else { + const ls = result.latest_session; + lines.push(` filename: ${ls.filename}`); + lines.push(` relative_path: ${ls.relative_path}`); + lines.push(` number: ${ls.number}`); + lines.push(` is_refinement: ${ls.is_refinement}`); + lines.push(` is_in_progress: ${ls.is_in_progress}`); + lines.push(` conclusion: ${ls.conclusion_text || '(empty)'}`); + } + lines.push(''); + + lines.push(`next_session_number: ${String(result.next_session_number).padStart(3, '0')}`); + + return lines.join('\n') + '\n'; +} + +if (require.main === module) { + const workUnit = process.argv[2]; + if (!workUnit) { + process.stderr.write('Error: work unit name required\nUsage: discovery.cjs \n'); + process.exit(1); + } + const result = discover(process.cwd(), workUnit); + process.stdout.write(format(result)); + if (result.error) { + process.exit(2); + } +} + +module.exports = { discover, format }; diff --git a/tests/scripts/test-discovery-for-refinement.cjs b/tests/scripts/test-discovery-for-refinement.cjs new file mode 100644 index 00000000..84bb92df --- /dev/null +++ b/tests/scripts/test-discovery-for-refinement.cjs @@ -0,0 +1,707 @@ +'use strict'; + +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const { setupFixture, cleanupFixture, createManifest, createFile } = require('./discovery-test-utils.cjs'); +const { discover, format } = require('../../skills/workflow-inception-process/scripts/discovery.cjs'); + +function writeSessionLog(dir, workUnit, number, conclusionBody, opts = {}) { + const padded = String(number).padStart(3, '0'); + const filename = `session-${padded}.md`; + const title = number === 1 ? 'Initial Framing' : 'Refinement'; + const trailing = opts.trailingSection ? `\n\n## ${opts.trailingSection}\n\nfoo\n` : '\n'; + const content = `# Inception Session ${padded} — ${title} + +Date: 2026-05-10 +Work unit: ${workUnit} + +## Map State at Start + +3 topics — 3 fresh + +## Self-Healing Arrivals + +(none) + +## Changes + +(none) + +## Conclusion + +${conclusionBody}${trailing}`; + createFile(dir, `.workflows/${workUnit}/inception/${filename}`, content); +} + +describe('workflow-inception-process discovery', () => { + let dir; + beforeEach(() => { dir = setupFixture(); }); + afterEach(() => { cleanupFixture(dir); }); + + // --- Error handling --- + + it('returns error for missing manifest', () => { + const r = discover(dir, 'nonexistent'); + assert.ok(r.error); + assert.match(r.error, /not found/i); + }); + + // --- Bare manifest shape --- + + it('returns work_unit verbatim', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.work_unit, 'payments'); + }); + + it('returns empty discovery_map when no inception phase exists', () => { + createManifest(dir, 'payments', { work_type: 'epic', phases: {} }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.discovery_map, []); + assert.strictEqual(r.map_summary.total, 0); + }); + + it('returns empty discovery_map when inception phase has no items', () => { + createManifest(dir, 'payments', { work_type: 'epic', phases: { inception: {} } }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.discovery_map, []); + }); + + it('returns empty discovery_map when inception items dict is empty', () => { + createManifest(dir, 'payments', { work_type: 'epic', phases: { inception: { items: {} } } }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.discovery_map, []); + }); + + // --- discovery_map content --- + + it('builds map entry with all named fields', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { + items: { + 'auth-flow': { status: 'in-progress', summary: 'oauth + sessions', routing: 'research', source: 'inception' }, + }, + }, + }, + }); + const r = discover(dir, 'payments'); + const t = r.discovery_map[0]; + assert.strictEqual(t.name, 'auth-flow'); + assert.strictEqual(t.summary, 'oauth + sessions'); + assert.strictEqual(t.routing, 'research'); + assert.strictEqual(t.source, 'inception'); + assert.strictEqual(t.lifecycle, 'fresh'); + assert.strictEqual(t.tier, '○'); + assert.strictEqual(t.current_phase, null); + assert.strictEqual(t.source_provenance, null); + }); + + it('defaults summary to null when missing', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].summary, null); + }); + + it('defaults routing to null when missing', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', source: 'inception' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].routing, null); + }); + + it('defaults source to "inception" when missing', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].source, 'inception'); + }); + + // --- Lifecycle reflection (one assertion per branch in computeTopicLifecycle) --- + + it('reflects fresh lifecycle when no research/discussion items exist', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'fresh'); + assert.strictEqual(r.discovery_map[0].tier, '○'); + }); + + it('reflects researching lifecycle when research is in-progress', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } }, + research: { items: { 'a': { status: 'in-progress' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'researching'); + assert.strictEqual(r.discovery_map[0].tier, '◐'); + assert.strictEqual(r.discovery_map[0].current_phase, 'research'); + }); + + it('reflects ready_for_discussion lifecycle when research is completed', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } }, + research: { items: { 'a': { status: 'completed' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'ready_for_discussion'); + assert.strictEqual(r.discovery_map[0].tier, '→'); + assert.strictEqual(r.discovery_map[0].current_phase, 'research'); + }); + + it('reflects discussing lifecycle when discussion is in-progress', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'discussion', source: 'inception' } } }, + discussion: { items: { 'a': { status: 'in-progress' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'discussing'); + assert.strictEqual(r.discovery_map[0].tier, '◐'); + assert.strictEqual(r.discovery_map[0].current_phase, 'discussion'); + }); + + it('reflects decided lifecycle when discussion is completed', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'discussion', source: 'inception' } } }, + discussion: { items: { 'a': { status: 'completed' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'decided'); + assert.strictEqual(r.discovery_map[0].tier, '✓'); + assert.strictEqual(r.discovery_map[0].current_phase, 'discussion'); + }); + + it('reflects cancelled lifecycle when both research and discussion are cancelled', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } }, + research: { items: { 'a': { status: 'cancelled' } } }, + discussion: { items: { 'a': { status: 'cancelled' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'cancelled'); + assert.strictEqual(r.discovery_map[0].tier, '⊘'); + }); + + it('falls through to fresh when only research is cancelled (alternate path open)', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } }, + research: { items: { 'a': { status: 'cancelled' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].lifecycle, 'fresh'); + }); + + // --- source_provenance --- + + it('source_provenance is null for source=inception', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].source_provenance, null); + }); + + it('source_provenance reads "from {source}" for non-inception sources', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'research-analysis' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].source_provenance, 'from research-analysis'); + }); + + it('source_provenance unwraps colon-prefixed sources to "from {parent}"', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'research-split:kitchen-hardware' } } } }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.discovery_map[0].source_provenance, 'from kitchen-hardware'); + }); + + // --- Sorting --- + + it('sorts by tier rank → first, then ◐, ✓, ○, ⊘', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { + items: { + 'fresh-item': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'inflight-item': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'ready-item': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'decided-item': { status: 'in-progress', routing: 'discussion', source: 'inception' }, + }, + }, + research: { items: { 'inflight-item': { status: 'in-progress' }, 'ready-item': { status: 'completed' } } }, + discussion: { items: { 'decided-item': { status: 'completed' } } }, + }, + }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual( + r.discovery_map.map(t => t.name), + ['ready-item', 'inflight-item', 'decided-item', 'fresh-item'], + ); + }); + + it('sorts alphabetically within the same tier', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { + items: { + 'zeta': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'alpha': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'mu': { status: 'in-progress', routing: 'research', source: 'inception' }, + }, + }, + }, + }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.discovery_map.map(t => t.name), ['alpha', 'mu', 'zeta']); + }); + + // --- map_summary --- + + it('map_summary aggregates counts across all tiers', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { + items: { + 'fresh-1': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'fresh-2': { status: 'in-progress', routing: 'discussion', source: 'inception' }, + 'inflight-1': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'ready-1': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'decided-1': { status: 'in-progress', routing: 'discussion', source: 'inception' }, + 'cancelled-1': { status: 'in-progress', routing: 'research', source: 'inception' }, + }, + }, + research: { + items: { + 'inflight-1': { status: 'in-progress' }, + 'ready-1': { status: 'completed' }, + 'cancelled-1': { status: 'cancelled' }, + }, + }, + discussion: { + items: { + 'decided-1': { status: 'completed' }, + 'cancelled-1': { status: 'cancelled' }, + }, + }, + }, + }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.map_summary.total, 6); + assert.strictEqual(r.map_summary.fresh, 2); + assert.strictEqual(r.map_summary.in_flight, 1); + assert.strictEqual(r.map_summary.ready, 1); + assert.strictEqual(r.map_summary.decided, 1); + assert.strictEqual(r.map_summary.cancelled, 1); + }); + + it('map_summary returns all-zero shape when no inception items exist', () => { + createManifest(dir, 'payments', { work_type: 'epic', phases: {} }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.map_summary, { + total: 0, decided: 0, in_flight: 0, ready: 0, fresh: 0, cancelled: 0, + }); + }); + + // --- dismissed list --- + + it('returns empty array when phases.inception.dismissed is missing', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: {} } }, + }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.dismissed, []); + }); + + it('returns the dismissed list verbatim and in order', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: {}, dismissed: ['first', 'second', 'third'] } }, + }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.dismissed, ['first', 'second', 'third']); + }); + + it('returns an empty array when dismissed is non-array (defensive)', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: {}, dismissed: 'invalid-shape' } }, + }); + const r = discover(dir, 'payments'); + assert.deepStrictEqual(r.dismissed, []); + }); + + it('does not mutate the manifest when reading dismissed', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: {}, dismissed: ['original'] } }, + }); + const r = discover(dir, 'payments'); + r.dismissed.push('mutation'); + const r2 = discover(dir, 'payments'); + assert.deepStrictEqual(r2.dismissed, ['original']); + }); + + // --- latest_session detection --- + + it('latest_session is null when no session logs exist', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session, null); + }); + + it('detects session-001.md as initial (is_refinement=false)', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, '3 topics seeded.'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.filename, 'session-001.md'); + assert.strictEqual(r.latest_session.number, 1); + assert.strictEqual(r.latest_session.is_refinement, false); + assert.strictEqual(r.latest_session.is_in_progress, false); + }); + + it('flags is_in_progress=true when Conclusion is "(none)"', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, '3 topics seeded.'); + writeSessionLog(dir, 'payments', 2, '(none)'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.number, 2); + assert.strictEqual(r.latest_session.is_refinement, true); + assert.strictEqual(r.latest_session.is_in_progress, true); + assert.strictEqual(r.latest_session.conclusion_text, '(none)'); + }); + + it('flags is_in_progress=false when Conclusion is concluded text', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, '3 topics seeded.'); + writeSessionLog(dir, 'payments', 2, '2 changes applied. Map now has 5 topics.'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.is_in_progress, false); + assert.strictEqual(r.latest_session.conclusion_text, '2 changes applied. Map now has 5 topics.'); + }); + + it('reads only the first line of Conclusion as conclusion_text', () => { + // Multi-line conclusion still picks first non-empty line. + const padded = '003'; + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'one'); + writeSessionLog(dir, 'payments', 2, 'two'); + const fullPath = path.join(dir, '.workflows', 'payments', 'inception', `session-${padded}.md`); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, `# Inception Session ${padded} — Refinement + +## Conclusion + +5 changes applied. Map now has 12 topics. + +Additional commentary on multiple lines. +`); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.number, 3); + assert.strictEqual(r.latest_session.conclusion_text, '5 changes applied. Map now has 12 topics.'); + assert.strictEqual(r.latest_session.is_in_progress, false); + }); + + it('terminates Conclusion read at the next ## heading', () => { + // Defensive: hand-edited logs may add sections after Conclusion. + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'concluded', { trailingSection: 'Postscript' }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.conclusion_text, 'concluded'); + }); + + it('treats missing Conclusion section as empty conclusion_text', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const fullPath = path.join(dir, '.workflows', 'payments', 'inception', 'session-001.md'); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, `# Inception Session 001 — Initial Framing\n\n## Map State at Start\n\n3 topics — 3 fresh\n`); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.conclusion_text, ''); + assert.strictEqual(r.latest_session.is_in_progress, false); + }); + + it('latest_session picks the highest-numbered file regardless of alphabetic order', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'concluded'); + writeSessionLog(dir, 'payments', 10, 'concluded'); + writeSessionLog(dir, 'payments', 2, 'concluded'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.number, 10); + assert.strictEqual(r.latest_session.filename, 'session-010.md'); + }); + + it('ignores non-matching filenames in inception directory', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'concluded'); + createFile(dir, '.workflows/payments/inception/session-abc.md', 'should be ignored'); + createFile(dir, '.workflows/payments/inception/notes.md', 'should be ignored'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.filename, 'session-001.md'); + }); + + it('relative_path is project-relative and forward-slashed', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 2, '(none)'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.latest_session.relative_path, '.workflows/payments/inception/session-002.md'); + }); + + // --- next_session_number --- + + it('next_session_number is 1 when no logs exist', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const r = discover(dir, 'payments'); + assert.strictEqual(r.next_session_number, 1); + }); + + it('next_session_number increments past the highest existing number', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'concluded'); + writeSessionLog(dir, 'payments', 2, 'concluded'); + writeSessionLog(dir, 'payments', 3, 'concluded'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.next_session_number, 4); + }); + + it('next_session_number still increments when latest is in-progress', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'concluded'); + writeSessionLog(dir, 'payments', 2, '(none)'); + const r = discover(dir, 'payments'); + assert.strictEqual(r.next_session_number, 3); + }); +}); + +describe('workflow-inception-process format', () => { + let dir; + beforeEach(() => { dir = setupFixture(); }); + afterEach(() => { cleanupFixture(dir); }); + + // --- Errors --- + + it('renders error result with "error:" prefix', () => { + const out = format({ error: 'Work unit "x" not found' }); + assert.match(out, /^error: Work unit "x" not found/); + }); + + // --- Header --- + + it('header line includes the work_unit name', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.match(out, /=== INCEPTION DISCOVERY: payments ===/); + }); + + // --- map_summary line --- + + it('map_summary line includes total and all six counts', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { + items: { + 'a': { status: 'in-progress', routing: 'research', source: 'inception' }, + 'b': { status: 'in-progress', routing: 'discussion', source: 'inception' }, + }, + }, + }, + }); + const out = format(discover(dir, 'payments')); + assert.match(out, /map_summary: 2 topics — 0 decided, 0 in-flight, 0 ready, 2 fresh, 0 cancelled/); + }); + + it('map_summary line for empty map reads "0 topics — ..."', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.match(out, /map_summary: 0 topics — 0 decided/); + }); + + // --- discovery_map block --- + + it('renders "(empty)" placeholder when discovery_map has no entries', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.match(out, /discovery_map \(0\):\n {2}\(empty\)/); + }); + + it('renders map row with tier, name, lifecycle, routing, summary', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { + items: { + 'auth-flow': { status: 'in-progress', summary: 'oauth', routing: 'research', source: 'inception' }, + }, + }, + }, + }); + const out = format(discover(dir, 'payments')); + assert.match(out, /- ○ auth-flow \[fresh\] routing=research — oauth/); + }); + + it('omits source from map row when source=inception', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } } }, + }); + const out = format(discover(dir, 'payments')); + assert.ok(!out.includes('source=inception')); + }); + + it('includes source=X in map row for non-inception sources', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'research-analysis' } } } }, + }); + const out = format(discover(dir, 'payments')); + assert.match(out, /source=research-analysis/); + }); + + it('includes phase=X in map row when current_phase is set', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { + inception: { items: { 'a': { status: 'in-progress', routing: 'research', source: 'inception' } } }, + research: { items: { 'a': { status: 'in-progress' } } }, + }, + }); + const out = format(discover(dir, 'payments')); + assert.match(out, /phase=research/); + }); + + it('omits routing= and summary suffix when those fields are null', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: { 'a': { status: 'in-progress' } } } }, + }); + const out = format(discover(dir, 'payments')); + assert.match(out, /- ○ a \[fresh\]/); + assert.ok(!out.includes('routing=')); + // Summary suffix is `— summary`; with no summary, no em dash should follow lifecycle. + assert.ok(!/\[fresh\] —/.test(out)); + }); + + // --- dismissed block --- + + it('renders "(none)" when dismissed list is empty', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.match(out, /dismissed \(0\):\n {2}\(none\)/); + }); + + it('renders dismissed names one per line', () => { + createManifest(dir, 'payments', { + work_type: 'epic', + phases: { inception: { items: {}, dismissed: ['old-thing', 'another'] } }, + }); + const out = format(discover(dir, 'payments')); + assert.match(out, /dismissed \(2\):\n {2}- old-thing\n {2}- another/); + }); + + // --- latest_session block --- + + it('renders "(no session logs on disk)" when latest_session is null', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.match(out, /latest_session:\n {2}\(no session logs on disk\)/); + }); + + it('renders all latest_session subfields when present', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 2, '(none)'); + const out = format(discover(dir, 'payments')); + assert.match(out, /filename: session-002\.md/); + assert.match(out, /relative_path: \.workflows\/payments\/inception\/session-002\.md/); + assert.match(out, /number: 2/); + assert.match(out, /is_refinement: true/); + assert.match(out, /is_in_progress: true/); + assert.match(out, /conclusion: \(none\)/); + }); + + it('renders conclusion as "(empty)" when conclusion_text is empty', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const fullPath = path.join(dir, '.workflows', 'payments', 'inception', 'session-001.md'); + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, `# Inception Session 001 — Initial Framing\n\n## Map State at Start\n\n3 topics\n`); + const out = format(discover(dir, 'payments')); + assert.match(out, /conclusion: \(empty\)/); + }); + + // --- next_session_number line --- + + it('next_session_number line is zero-padded to 3 digits', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.match(out, /next_session_number: 001/); + }); + + it('next_session_number increments past the highest existing number', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + writeSessionLog(dir, 'payments', 1, 'concluded'); + writeSessionLog(dir, 'payments', 9, 'concluded'); + const out = format(discover(dir, 'payments')); + assert.match(out, /next_session_number: 010/); + }); + + // --- Output structure --- + + it('output ends with a trailing newline', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + assert.ok(out.endsWith('\n')); + }); + + it('renders all five named sections in order', () => { + createManifest(dir, 'payments', { work_type: 'epic' }); + const out = format(discover(dir, 'payments')); + const order = [ + out.indexOf('=== INCEPTION DISCOVERY:'), + out.indexOf('map_summary:'), + out.indexOf('discovery_map ('), + out.indexOf('dismissed ('), + out.indexOf('latest_session:'), + out.indexOf('next_session_number:'), + ]; + for (let i = 1; i < order.length; i++) { + assert.ok(order[i] > order[i - 1], `section ${i} must follow section ${i - 1}; got ${order}`); + } + }); +}); diff --git a/tests/scripts/test-refinement-session.cjs b/tests/scripts/test-refinement-session.cjs new file mode 100644 index 00000000..e0cff836 --- /dev/null +++ b/tests/scripts/test-refinement-session.cjs @@ -0,0 +1,287 @@ +'use strict'; + +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { spawnSync } = require('child_process'); + +const MANIFEST_CLI = path.resolve( + __dirname, '..', '..', 'skills', 'workflow-manifest', 'scripts', 'manifest.cjs' +); +const { computeTopicLifecycle } = require( + path.resolve(__dirname, '..', '..', 'skills', 'workflow-shared', 'scripts', 'discovery-utils.cjs') +); + +let dir; + +function setupFixture() { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'refinement-test-')); + fs.mkdirSync(path.join(dir, '.workflows'), { recursive: true }); +} + +function cleanupFixture() { + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = null; +} + +function runCli(...args) { + const result = spawnSync('node', [MANIFEST_CLI, ...args], { cwd: dir, encoding: 'utf8' }); + return { + stdout: result.stdout, + stderr: result.stderr, + status: result.status, + }; +} + +function readManifest(workUnit) { + return JSON.parse(fs.readFileSync( + path.join(dir, '.workflows', workUnit, 'manifest.json'), 'utf8' + )); +} + +function seedEpic(workUnit, items = {}) { + const manifestDir = path.join(dir, '.workflows', workUnit); + fs.mkdirSync(manifestDir, { recursive: true }); + const manifest = { + name: workUnit, + work_type: 'epic', + status: 'in-progress', + description: `Test: ${workUnit}`, + phases: { inception: { items } }, + }; + fs.writeFileSync( + path.join(manifestDir, 'manifest.json'), + JSON.stringify(manifest, null, 2), + ); + // Register in project manifest so workUnitNames() lists it. + const projDir = path.join(dir, '.workflows'); + const projPath = path.join(projDir, 'manifest.json'); + let proj = {}; + if (fs.existsSync(projPath)) { + proj = JSON.parse(fs.readFileSync(projPath, 'utf8')); + } + if (!proj.work_units) proj.work_units = {}; + proj.work_units[workUnit] = { work_type: 'epic' }; + fs.writeFileSync(projPath, JSON.stringify(proj, null, 2)); +} + +describe('refinement: hard-delete on remove', () => { + beforeEach(setupFixture); + afterEach(cleanupFixture); + + it('removes the named topic and leaves siblings intact', () => { + seedEpic('payments', { + 'auth-flow': { status: 'in-progress', summary: 'auth', routing: 'research', source: 'inception' }, + 'billing-history': { status: 'in-progress', summary: 'billing', routing: 'discussion', source: 'inception' }, + 'tax-handling': { status: 'in-progress', summary: 'tax', routing: 'research', source: 'inception' }, + }); + + const r = runCli('delete', 'payments.inception', 'items.billing-history'); + assert.strictEqual(r.status, 0, `delete failed: ${r.stderr}`); + + const m = readManifest('payments'); + assert.ok(!('billing-history' in m.phases.inception.items), 'billing-history still present'); + assert.ok('auth-flow' in m.phases.inception.items, 'auth-flow was removed'); + assert.ok('tax-handling' in m.phases.inception.items, 'tax-handling was removed'); + }); + + it('returns expected-miss exit code when the topic does not exist', () => { + seedEpic('payments', { + 'auth-flow': { status: 'in-progress', routing: 'research', source: 'inception' }, + }); + + const r = runCli('delete', 'payments.inception', 'items.nonexistent'); + assert.strictEqual(r.status, 2, `unexpected exit code: ${r.stderr}`); + }); +}); + +describe('refinement: dismissed list', () => { + beforeEach(setupFixture); + afterEach(cleanupFixture); + + it('push adds a name to phases.inception.dismissed', () => { + seedEpic('payments', { + 'auth-flow': { status: 'in-progress', routing: 'research', source: 'inception' }, + }); + + const r = runCli('push', 'payments.inception', 'dismissed', 'old-topic'); + assert.strictEqual(r.status, 0, `push failed: ${r.stderr}`); + + const m = readManifest('payments'); + assert.deepStrictEqual(m.phases.inception.dismissed, ['old-topic']); + }); + + it('push appends multiple names in order', () => { + seedEpic('payments', {}); + runCli('push', 'payments.inception', 'dismissed', 'first'); + runCli('push', 'payments.inception', 'dismissed', 'second'); + runCli('push', 'payments.inception', 'dismissed', 'third'); + + const m = readManifest('payments'); + assert.deepStrictEqual(m.phases.inception.dismissed, ['first', 'second', 'third']); + }); + + it('pull removes a named entry from the dismissed list', () => { + seedEpic('payments', {}); + runCli('push', 'payments.inception', 'dismissed', 'first'); + runCli('push', 'payments.inception', 'dismissed', 'second'); + runCli('push', 'payments.inception', 'dismissed', 'third'); + + const r = runCli('pull', 'payments.inception', 'dismissed', 'second'); + assert.strictEqual(r.status, 0, `pull failed: ${r.stderr}`); + + const m = readManifest('payments'); + assert.deepStrictEqual(m.phases.inception.dismissed, ['first', 'third']); + }); + + it('pull is a no-op when the entry is missing', () => { + seedEpic('payments', {}); + runCli('push', 'payments.inception', 'dismissed', 'first'); + + const r = runCli('pull', 'payments.inception', 'dismissed', 'nonexistent'); + assert.strictEqual(r.status, 0); + + const m = readManifest('payments'); + assert.deepStrictEqual(m.phases.inception.dismissed, ['first']); + }); + + it('get returns the dismissed list as JSON', () => { + seedEpic('payments', {}); + runCli('push', 'payments.inception', 'dismissed', 'a'); + runCli('push', 'payments.inception', 'dismissed', 'b'); + + const r = runCli('get', 'payments.inception', 'dismissed'); + assert.strictEqual(r.status, 0); + const parsed = JSON.parse(r.stdout); + assert.deepStrictEqual(parsed, ['a', 'b']); + }); +}); + +describe('refinement: rename mechanical sequence', () => { + beforeEach(setupFixture); + afterEach(cleanupFixture); + + it('preserves summary, routing, and source after a rename', () => { + seedEpic('payments', { + 'old-name': { + status: 'in-progress', + summary: 'an old summary', + routing: 'research', + source: 'inception', + }, + }); + + // 1. Read old fields (scalar values come back as raw text + trailing newline) + const summary = runCli('get', 'payments.inception.old-name', 'summary').stdout.trimEnd(); + const routing = runCli('get', 'payments.inception.old-name', 'routing').stdout.trimEnd(); + const src = runCli('get', 'payments.inception.old-name', 'source').stdout.trimEnd(); + + assert.strictEqual(summary, 'an old summary'); + assert.strictEqual(routing, 'research'); + assert.strictEqual(src, 'inception'); + + // 2. Delete old key, init new key, write fields + assert.strictEqual(runCli('delete', 'payments.inception', 'items.old-name').status, 0); + assert.strictEqual(runCli('init-phase', 'payments.inception.new-name').status, 0); + assert.strictEqual(runCli('set', 'payments.inception.new-name', 'summary', summary).status, 0); + assert.strictEqual(runCli('set', 'payments.inception.new-name', 'routing', routing).status, 0); + assert.strictEqual(runCli('set', 'payments.inception.new-name', 'source', src).status, 0); + + const m = readManifest('payments'); + assert.ok(!('old-name' in m.phases.inception.items), 'old-name still present'); + assert.ok('new-name' in m.phases.inception.items, 'new-name not created'); + assert.strictEqual(m.phases.inception.items['new-name'].summary, 'an old summary'); + assert.strictEqual(m.phases.inception.items['new-name'].routing, 'research'); + assert.strictEqual(m.phases.inception.items['new-name'].source, 'inception'); + assert.strictEqual(m.phases.inception.items['new-name'].status, 'in-progress'); + }); +}); + +describe('refinement: lifecycle gate via computeTopicLifecycle', () => { + beforeEach(setupFixture); + afterEach(cleanupFixture); + + it('reports fresh for a topic with no research/discussion items', () => { + const manifest = { + phases: { + inception: { items: { 'newtopic': { status: 'in-progress', routing: 'research' } } }, + }, + }; + const lc = computeTopicLifecycle(manifest, 'newtopic'); + assert.strictEqual(lc.lifecycle, 'fresh'); + assert.strictEqual(lc.tier, '○'); + }); + + it('reports researching when research is in-progress', () => { + const manifest = { + phases: { + inception: { items: { 'auth': { status: 'in-progress', routing: 'research' } } }, + research: { items: { 'auth': { status: 'in-progress' } } }, + }, + }; + const lc = computeTopicLifecycle(manifest, 'auth'); + assert.strictEqual(lc.lifecycle, 'researching'); + assert.strictEqual(lc.tier, '◐'); + }); + + it('reports discussing when discussion is in-progress', () => { + const manifest = { + phases: { + inception: { items: { 'auth': { status: 'in-progress', routing: 'discussion' } } }, + discussion: { items: { 'auth': { status: 'in-progress' } } }, + }, + }; + const lc = computeTopicLifecycle(manifest, 'auth'); + assert.strictEqual(lc.lifecycle, 'discussing'); + }); + + it('reports decided when discussion is completed', () => { + const manifest = { + phases: { + inception: { items: { 'auth': { status: 'in-progress', routing: 'discussion' } } }, + discussion: { items: { 'auth': { status: 'completed' } } }, + }, + }; + const lc = computeTopicLifecycle(manifest, 'auth'); + assert.strictEqual(lc.lifecycle, 'decided'); + }); + + it('reports cancelled only when both research and discussion are cancelled', () => { + const both = { + phases: { + inception: { items: { 'auth': { status: 'in-progress', routing: 'research' } } }, + research: { items: { 'auth': { status: 'cancelled' } } }, + discussion: { items: { 'auth': { status: 'cancelled' } } }, + }, + }; + assert.strictEqual(computeTopicLifecycle(both, 'auth').lifecycle, 'cancelled'); + + const onlyResearchCancelled = { + phases: { + inception: { items: { 'auth': { status: 'in-progress', routing: 'research' } } }, + research: { items: { 'auth': { status: 'cancelled' } } }, + }, + }; + assert.strictEqual(computeTopicLifecycle(onlyResearchCancelled, 'auth').lifecycle, 'fresh'); + }); + + it('only fresh is allowed for destructive map operations', () => { + const lifecycles = [ + { lifecycle: 'fresh', allowed: true }, + { lifecycle: 'researching', allowed: false }, + { lifecycle: 'ready_for_discussion', allowed: false }, + { lifecycle: 'discussing', allowed: false }, + { lifecycle: 'decided', allowed: false }, + { lifecycle: 'cancelled', allowed: false }, + ]; + const isAllowed = lc => lc === 'fresh'; + for (const { lifecycle, allowed } of lifecycles) { + assert.strictEqual( + isAllowed(lifecycle), allowed, + `lifecycle "${lifecycle}" expected allowed=${allowed}` + ); + } + }); +});