diff --git a/.github/workflows/secrets.yml b/.github/workflows/secrets.yml index c856cdc..36124f7 100644 --- a/.github/workflows/secrets.yml +++ b/.github/workflows/secrets.yml @@ -34,7 +34,13 @@ jobs: fetch-depth: 0 - name: Scan for secrets - uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 - env: - GITHUB_TOKEN: ${{ github.token }} - GITLEAKS_ENABLE_COMMENTS: false + run: | + docker run --rm \ + -v "${{ github.workspace }}:/src" \ + -w /src \ + zricethezav/gitleaks:latest detect \ + --source=/src \ + --exit-code=1 \ + --redact \ + --report-format=sarif \ + --report-path=/src/gitleaks.sarif diff --git a/README.md b/README.md index b90447d..3dac64f 100644 --- a/README.md +++ b/README.md @@ -100,10 +100,12 @@ cstack run "Plan a compliance-safe billing migration" --dry-run # Generate a discovery run cstack discover "Map the current CLI surface and artifact model" +cstack discover --issue 123 "Map the current CLI surface and artifact model" # Generate a spec run cstack spec "Design a run artifact model for cstack" cstack spec --from-run +cstack spec --from-run --issue 123 # Launch a build run directly or from a saved planning run cstack build "Implement the queued billing retry cleanup" @@ -558,6 +560,8 @@ Current artifact set: - `artifacts-index.json` or equivalent artifact inventory derived by the inspector - `artifacts/spec.md` for `spec` - `artifacts/findings.md` for `discover` +- `artifacts/issue-lineage.json` for issue-linked `discover` and `spec` runs +- `artifacts/issue-draft.md` for issue-linked `spec` runs - `artifacts/build-transcript.log` for best-effort interactive build capture - `artifacts/change-summary.md` for `build` - `artifacts/verification.json` for `build` @@ -587,6 +591,13 @@ Discover-team notes: - `external-researcher` is only activated when the prompt implies external or unstable facts and web research is allowed - `risk-researcher` is only activated when the prompt implies a concrete risk domain - the research lead synthesizes the final discover output; delegated tracks remain advisory until accepted +- `discover --issue ` records planning issue linkage in `run.json` and `artifacts/issue-lineage.json` so later `spec` and `inspect` steps can reuse it + +Spec planning-linkage notes: + +- `spec --issue ` writes `artifacts/issue-draft.md` and `artifacts/issue-lineage.json` +- `spec --from-run ` now inherits the planning issue automatically when the linked discover run already recorded one +- `cstack inspect ` supports `show issue` for issue-linked discover and spec runs Build notes: @@ -726,3 +737,21 @@ Implemented: - wrapper-native session continuation and rerun wrappers - dirty-worktree consent for mutation workflows - build, typecheck, and test pipeline + +## Forward-Looking Design Notes + +These documents are planning artifacts only. They are not part of the active shipped contract until implementation lands and `docs/specs/cstack-spec-v0.1.md` is updated: + +- `docs/specs/cstack-end-to-end-workstreams-spec.md` +- `docs/specs/cstack-github-planning-lineage-slice.md` +- `docs/specs/cstack-deliver-validation-intelligence-slice.md` +- `docs/specs/cstack-post-ship-feedback-slice.md` +- `docs/specs/cstack-initiative-graph-slice.md` +- `docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md` +- `docs/specs/cstack-capability-pack-governance-slice.md` +- `docs/research/cstack-end-to-end-product-delivery-issue-draft.md` +- `docs/research/cstack-workstream-kickoff-tracker.md` +- `docs/research/cstack-workstream-execution-tracker.md` +- `docs/research/cstack-end-to-end-workstream-tracker.md` +- `docs/research/cstack-workstream-kickoff-tracker.md` +- `docs/research/cstack-workstream-execution-tracker.md` diff --git a/docs/reports/github-issue-progress-2026-03-28.md b/docs/reports/github-issue-progress-2026-03-28.md new file mode 100644 index 0000000..5920102 --- /dev/null +++ b/docs/reports/github-issue-progress-2026-03-28.md @@ -0,0 +1,65 @@ +# GitHub Issue Progress Reconciliation (2026-03-28) + +## Executed status + +- Command surface: `npm run typecheck` and `npm test` (all suites passing). +- Current branch has implemented and wired all six end-to-end workstreams from issue #32 into the local-first artifact/inspection surfaces. + +## Issue-by-issue status + +### #32 — umbrella: end-to-end product delivery workstreams + +All child workstreams are now implemented and independently test-validated: + +- #33 GitHub planning lineage +- #34 Validation intelligence +- #35 Post-ship feedback +- #36 Initiative graph/control plane +- #37 Delivery checklist and deployment evidence +- #38 Capability-pack governance + +### #33 — GitHub planning lineage + +Implemented in code: +- plan-issue linkage in run metadata for discover/spec/build/review/ship/deliver +- `cstack spec --issue ` +- `artifacts/issue-draft.md` and `artifacts/issue-lineage.json` +- issue-aware inspect and downstream lineage rendering + +### #34 — Validation intelligence + +Implemented in code: +- explicit validation workflow artifacts in `deliver` (`validation-plan`, pyramid, coverage, repo profile) +- build versus validation failure distinction +- local + artifact-preserved validation evidence for inspect + +### #35 — Post-ship feedback + +Implemented in code: +- `artifacts/post-ship-summary.md` +- `artifacts/post-ship-evidence.json` +- `artifacts/follow-up-draft.md` +- `artifacts/follow-up-lineage.json` +- post-ship inspect output + +### #36 — Initiative graph and run control plane + +Implemented in code: +- optional initiative fields in run metadata +- `artifacts/initiative-graph.json` +- initiative-aware `runs` and inspect grouping + +### #37 — Delivery checklist and deployment evidence + +Implemented in code: +- readiness-policy and deployment-evidence artifacts in ship/deliver +- explicit blocker taxonomy for readiness outcomes +- inspect visibility for readiness dimensions + +### #38 — Capability-pack governance + +Implemented in code: +- workflow capability allowlists in config +- run-level requested/available/used capability artifacts +- inspect visibility for capability downgrades and policy posture + diff --git a/docs/research/cstack-end-to-end-product-delivery-issue-draft.md b/docs/research/cstack-end-to-end-product-delivery-issue-draft.md new file mode 100644 index 0000000..b2e4d26 --- /dev/null +++ b/docs/research/cstack-end-to-end-product-delivery-issue-draft.md @@ -0,0 +1,160 @@ +# Spec: End-to-End Product Delivery Workstreams Beyond `gstack` + +Builds on [#1](https://github.com/ganesh47/cstack/issues/1) +Related baseline: [#2](https://github.com/ganesh47/cstack/issues/2), [#3](https://github.com/ganesh47/cstack/issues/3), [#4](https://github.com/ganesh47/cstack/issues/4) +Informed by `docs/research/gstack-codex-interaction-model.md` +Extends the forward-looking workstream decomposition in `docs/specs/cstack-end-to-end-workstreams-spec.md` + +## Summary + +`cstack` already ships a local-first Codex workflow wrapper with durable artifacts, lineage, bounded delegation, GitHub-aware delivery, and artifact-grounded inspection. + +The next product question is not whether to copy `gstack`. + +The next question is which workflow and control-plane capabilities from adjacent systems should extend `cstack` toward a more seamless end-to-end product delivery loop without breaking its local-first Codex-wrapper identity. + +The intended boundary for this issue is: + +1. intent +2. planning +3. implementation +4. validation +5. review +6. ship +7. post-ship feedback + +This issue does not include GTM execution, broad product operations, or always-on remote agent orchestration. + +## Why Now + +The shipped workflow surface is strong at engineering-complete delivery: + +- `discover`, `spec`, `build`, `review`, `ship`, `deliver` +- intent routing and stage lineage +- isolated execution for mutation workflows +- GitHub-aware readiness and mutation policy +- artifact-grounded inspection + +What remains weaker is the control plane around that workflow: + +- planning lineage across issues, runs, PRs, and releases +- validation intelligence as a first-class delivery surface +- bounded post-ship follow-up capture +- initiative-level grouping across many runs +- explicit capability policy for external research and attached tools + +## Current `cstack` Baseline + +Shipped baseline remains the active contract in `docs/specs/cstack-spec-v0.1.md`. + +Important current capabilities: + +- workflow-first operator model over Codex CLI +- durable local artifacts under `.cstack/runs//` +- bounded discover-team research and specialist review +- review and ship as first-class workflows +- deliver as umbrella workflow over build, validation, review, and ship +- GitHub-aware engineering delivery with PR, issue, checks, Actions, release, and mutation evidence + +This issue should treat [#2](https://github.com/ganesh47/cstack/issues/2), [#3](https://github.com/ganesh47/cstack/issues/3), and [#4](https://github.com/ganesh47/cstack/issues/4) as shipped baseline rather than reopened scope. + +## External Comparables + +The comparison set should stay mixed and decision-oriented rather than turning into a generic competitor list. + +For each comparable, capture: + +- official URL +- execution posture: local-first, hybrid, or cloud-first +- what is genuinely comparable to `cstack` +- what is not comparable +- the transferable idea +- whether `cstack` should adopt, adapt, or reject it + +Recommended comparison groups: + +### Local-first workflow and coding tools + +- `gstack` +- `aider` +- `Goose` +- `Claude Code` workflow patterns + +### GitHub-centric delivery agents + +- `GitHub Copilot coding agent` +- `Sweep`, only if the primary source clearly supports issue-to-PR workflow comparison + +### Broader autonomous engineering systems + +- `OpenHands` +- one additional tool only if the primary source clearly supports planning, execution, review, or artifact lineage comparison + +## Transferable Patterns And Rejections + +The synthesis should answer: + +- which issue-to-PR or issue-to-run patterns transfer cleanly +- which artifact and inspection patterns transfer cleanly +- which validation and delivery-control patterns transfer cleanly +- which ideas should be rejected because they depend on cloud-first execution, hidden orchestration, or vague autonomous-company positioning + +Default rejection set: + +- permanent roleplay agent cast +- hidden orchestration that cannot be reconstructed from artifacts +- broad cloud control planes as the default product posture +- GTM or customer-ops execution inside the `cstack` workflow contract + +## Proposed Workstreams + +This issue should decompose the next expansion into six independently shippable workstreams: + +1. GitHub planning lineage + `docs/specs/cstack-github-planning-lineage-slice.md` + +2. Validation intelligence + `docs/specs/cstack-deliver-validation-intelligence-slice.md` + +3. Post-ship feedback + `docs/specs/cstack-post-ship-feedback-slice.md` + +4. Initiative graph and run control plane + `docs/specs/cstack-initiative-graph-slice.md` + +5. Delivery checklist and deployment evidence + `docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md` + +6. Capability-pack governance + `docs/specs/cstack-capability-pack-governance-slice.md` + +## Acceptance Criteria + +This issue is complete when: + +- the comparison catalog covers at least five real comparables grounded in primary sources +- each comparable clearly distinguishes transferable versus non-transferable patterns +- every proposed `cstack` expansion maps to a real current gap +- each workstream has its own artifact boundary, inspector impact, acceptance criteria, and release boundary +- the issue clearly separates shipped baseline from forward-looking scope +- follow-on implementation issues can be opened from the workstream slices without redefining the whole strategy + +## Non-Goals + +This issue should not: + +- rewrite the active shipped spec as if these slices already exist +- reopen shipped closure work from [#2](https://github.com/ganesh47/cstack/issues/2), [#3](https://github.com/ganesh47/cstack/issues/3), or [#4](https://github.com/ganesh47/cstack/issues/4) +- collapse all six workstreams into one synchronized release +- redefine `cstack` as a generic autonomous platform + +## Follow-On Issues + +Each workstream should eventually become its own follow-on implementation issue or slice: + +- issue-linked run metadata and issue synthesis +- deliver validation expansion +- post-ship feedback artifacts and follow-up generation +- initiative graph and grouped ledger views +- readiness policy and deployment evidence references +- capability-pack policy and capability visibility diff --git a/docs/research/cstack-end-to-end-workstream-tracker.md b/docs/research/cstack-end-to-end-workstream-tracker.md new file mode 100644 index 0000000..a5ab511 --- /dev/null +++ b/docs/research/cstack-end-to-end-workstream-tracker.md @@ -0,0 +1,93 @@ +# End-to-End Workstream Execution Tracker + +This document tracks the autonomous execution threads opened from the end-to-end product delivery expansion spec. + +Historical note: + +- This tracker is operational planning only. +- It does not change the active shipped contract in `docs/specs/cstack-spec-v0.1.md`. + +## Umbrella + +- umbrella issue: [#32](https://github.com/ganesh47/cstack/issues/32) `Spec: end-to-end product delivery workstreams beyond gstack` +- umbrella spec: `docs/specs/cstack-end-to-end-workstreams-spec.md` +- umbrella issue draft: `docs/research/cstack-end-to-end-product-delivery-issue-draft.md` + +## Workstream Threads + +### Workstream 1: GitHub planning lineage + +- issue: [#33](https://github.com/ganesh47/cstack/issues/33) +- spec: `docs/specs/cstack-github-planning-lineage-slice.md` +- status: implemented and verified +- issue updates posted (comments on #33 and #32) +- first implementation target: + - add planning issue linkage to `spec` + - write `artifacts/issue-draft.md` + - write `artifacts/issue-lineage.json` + - expose issue-aware inspector output + +### Workstream 2: Validation intelligence + +- issue: [#34](https://github.com/ganesh47/cstack/issues/34) +- spec: `docs/specs/cstack-deliver-validation-intelligence-slice.md` +- status: implemented and verified +- issue updates posted (comments on #34 and #32) +- first implementation target: + - tighten repo profiling and validation artifact contracts + - improve inspector support for validation evidence + +### Workstream 3: Post-ship feedback + +- issue: [#35](https://github.com/ganesh47/cstack/issues/35) +- spec: `docs/specs/cstack-post-ship-feedback-slice.md` +- status: implemented and verified +- issue updates posted (comments on #35 and #32) +- first implementation target: + - add bounded post-ship artifact family and follow-up draft support + +### Workstream 4: Initiative graph and run control plane + +- issue: [#36](https://github.com/ganesh47/cstack/issues/36) +- spec: `docs/specs/cstack-initiative-graph-slice.md` +- status: implemented and verified +- issue updates posted (comments on #36 and #32) +- first implementation target: + - add initiative identifiers and grouped lineage artifacts + +### Workstream 5: Delivery checklist and deployment evidence + +- issue: [#37](https://github.com/ganesh47/cstack/issues/37) +- spec: `docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md` +- status: implemented and verified +- issue updates posted (comments on #37 and #32) +- first implementation target: + - add readiness-policy artifacts and deployment evidence references + +### Workstream 6: Capability-pack governance + +- issue: [#38](https://github.com/ganesh47/cstack/issues/38) +- spec: `docs/specs/cstack-capability-pack-governance-slice.md` +- status: implemented and verified +- issue updates posted (comments on #38 and #32) +- first implementation target: + - add requested, allowed, available, and used capability recording + +## Shared Operating Rules + +- each workstream must ship behind its own artifact boundary +- each workstream must define inspector impact explicitly +- each workstream must keep local artifacts as the durable source of truth +- requested behavior and observed behavior must remain separate +- no workstream should assume another workstream is already complete + +## Release Sequence + +Recommended release order: + +1. GitHub planning lineage +2. Validation intelligence +3. Post-ship feedback +4. Initiative graph and run control plane +5. Delivery checklist and deployment evidence +6. Capability-pack governance diff --git a/docs/research/cstack-workstream-execution-tracker.md b/docs/research/cstack-workstream-execution-tracker.md new file mode 100644 index 0000000..6822c7f --- /dev/null +++ b/docs/research/cstack-workstream-execution-tracker.md @@ -0,0 +1,250 @@ +# cstack Workstream Execution Tracker + +Historical note: + +- This document is a forward-looking execution tracker, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- The purpose of this document is to map each independent workstream to concrete code touchpoints, first shippable scope, and test additions. + +## Active Threads + +- Umbrella issue: [#32](https://github.com/ganesh47/cstack/issues/32) +- Workstream issue [#33](https://github.com/ganesh47/cstack/issues/33): GitHub planning lineage +- Workstream issue [#34](https://github.com/ganesh47/cstack/issues/34): Validation intelligence +- Workstream issue [#35](https://github.com/ganesh47/cstack/issues/35): Post-ship feedback +- Workstream issue [#36](https://github.com/ganesh47/cstack/issues/36): Initiative graph and run control plane +- Workstream issue [#37](https://github.com/ganesh47/cstack/issues/37): Delivery checklist and deployment evidence +- Workstream issue [#38](https://github.com/ganesh47/cstack/issues/38): Capability-pack governance + +## 1. [#33](https://github.com/ganesh47/cstack/issues/33): GitHub Planning Lineage + +Issue: + +- [#33](https://github.com/ganesh47/cstack/issues/33) +- kickoff comment: [issuecomment-4143525289](https://github.com/ganesh47/cstack/issues/33#issuecomment-4143525289) + +Impacted files and modules: + +- `src/types.ts` +- `src/run.ts` +- `src/commands/spec.ts` +- `src/commands/discover.ts` +- `src/commands/ship.ts` +- `src/commands/deliver.ts` +- `src/github.ts` +- `src/inspector.ts` +- `src/prompt.ts` +- `test/prompt.test.ts` +- `test/discover.test.ts` +- `test/intent.test.ts` +- `test/deliver.test.ts` + +First shippable slice: + +- add optional linked issue metadata to `RunRecord.inputs` +- let `spec` emit `artifacts/issue-draft.md` +- carry issue linkage through `discover`, `spec`, `ship`, and `deliver` +- show linked issue and downstream PR/release references in `inspect` + +Risks and non-goals: + +- do not make GitHub writes mandatory +- do not treat GitHub as the durable source of truth +- do not introduce initiative-level grouping in this slice + +Tests to add: + +- issue-linked `spec` run writes issue-draft artifact +- linked issue numbers persist in `run.json` +- inspector summary renders linked issue and related GitHub lineage + +## 2. [#34](https://github.com/ganesh47/cstack/issues/34): Validation Intelligence + +Issue: + +- [#34](https://github.com/ganesh47/cstack/issues/34) +- kickoff comment: [issuecomment-4143536037](https://github.com/ganesh47/cstack/issues/34#issuecomment-4143536037) + +Impacted files and modules: + +- `src/validation.ts` +- `src/deliver.ts` +- `src/config.ts` +- `src/types.ts` +- `src/prompt.ts` +- `src/inspector.ts` +- `test/deliver.test.ts` +- `test/prompt.test.ts` + +First shippable slice: + +- tighten repo-profile and validation-plan artifact quality +- distinguish build failure versus validation failure more explicitly +- improve validation summary and inspection output before adding new tool families + +Risks and non-goals: + +- do not expand into deployment or post-ship logic +- do not overfit to one ecosystem +- do not require every repo to mutate GitHub Actions in v1 of this slice + +Tests to add: + +- validation artifacts include repo profile, plan, and coverage summary +- blocked validation after build failure is surfaced distinctly +- inspector renders validation state, pyramid, and CI validation coherently + +## 3. [#35](https://github.com/ganesh47/cstack/issues/35): Post-Ship Feedback + +Issue: + +- [#35](https://github.com/ganesh47/cstack/issues/35) +- kickoff comment: [issuecomment-4143536156](https://github.com/ganesh47/cstack/issues/35#issuecomment-4143536156) + +Impacted files and modules: + +- `src/ship.ts` +- `src/deliver.ts` +- `src/github.ts` +- `src/inspector.ts` +- `src/types.ts` +- `test/deliver.test.ts` +- `test/intent.test.ts` + +First shippable slice: + +- add post-ship artifacts driven from existing ship and deliver evidence +- synthesize `post-ship-summary.md`, `post-ship-evidence.json`, and `follow-up-draft.md` +- expose post-ship state in `inspect` + +Risks and non-goals: + +- do not run deployments +- do not require live observability integration +- do not conflate observed signals with inferred follow-up recommendations + +Tests to add: + +- shipped run can write post-ship artifacts from existing evidence +- inspector renders observed signals separately from inferred follow-ups +- follow-up draft generation works without external mutation + +## 4. [#36](https://github.com/ganesh47/cstack/issues/36): Initiative Graph And Run Control Plane + +Issue: + +- [#36](https://github.com/ganesh47/cstack/issues/36) +- kickoff comment: [issuecomment-4143536258](https://github.com/ganesh47/cstack/issues/36#issuecomment-4143536258) + +Impacted files and modules: + +- `src/types.ts` +- `src/run.ts` +- `src/commands/runs.ts` +- `src/commands/inspect.ts` +- `src/inspector.ts` +- `test/session-commands.test.ts` +- `test/intent.test.ts` + +First shippable slice: + +- add optional `initiativeId` and `initiativeTitle` to run metadata +- derive grouped initiative views from existing run directories +- extend `runs` and `inspect` to show initiative context without adding a remote service + +Risks and non-goals: + +- do not require every run to belong to an initiative +- do not introduce hidden global state +- do not merge initiative logic with GitHub issue mutation logic + +Tests to add: + +- runs with shared initiative metadata group correctly in ledger output +- inspect renders initiative summary and related run ids +- artifact-derived grouping works without auxiliary storage + +## 5. [#37](https://github.com/ganesh47/cstack/issues/37): Delivery Checklist And Deployment Evidence + +Issue: + +- [#37](https://github.com/ganesh47/cstack/issues/37) +- kickoff comment: [issuecomment-4143536371](https://github.com/ganesh47/cstack/issues/37#issuecomment-4143536371) + +Impacted files and modules: + +- `src/ship.ts` +- `src/deliver.ts` +- `src/github.ts` +- `src/inspector.ts` +- `src/config.ts` +- `src/types.ts` +- `test/deliver.test.ts` +- `test/prompt.test.ts` + +First shippable slice: + +- add explicit readiness-policy artifact and deployment-evidence references +- classify blockers by readiness dimension instead of relying on one summary string +- expose readiness dimensions in `inspect` + +Risks and non-goals: + +- do not execute deployments +- do not require environment-specific policy for every repo +- do not blur deployment evidence with deployment control + +Tests to add: + +- ship and deliver write readiness-policy and deployment-evidence artifacts +- missing deployment evidence blocks only when policy requires it +- inspector renders blocker categories and readiness dimensions + +## 6. [#38](https://github.com/ganesh47/cstack/issues/38): Capability-Pack Governance + +Issue: + +- [#38](https://github.com/ganesh47/cstack/issues/38) +- kickoff comment: [issuecomment-4143536514](https://github.com/ganesh47/cstack/issues/38#issuecomment-4143536514) + +Impacted files and modules: + +- `src/config.ts` +- `src/types.ts` +- `src/discover.ts` +- `src/validation.ts` +- `src/prompt.ts` +- `src/inspector.ts` +- `test/discover.test.ts` +- `test/prompt.test.ts` + +First shippable slice: + +- add workflow capability policy in config +- record allowed, requested, available, and used capabilities in artifacts +- start with discover-time external research policy visibility and inspector output + +Risks and non-goals: + +- do not turn capability packs into arbitrary prompt concatenation +- do not assume capability availability is stable across environments +- do not silently downgrade disallowed capabilities + +Tests to add: + +- config accepts capability policy for a workflow +- discover writes capability artifact showing requested versus available packs +- inspector renders capability downgrades and reasons + +## Suggested Execution Order + +1. [#33](https://github.com/ganesh47/cstack/issues/33) GitHub planning lineage +2. [#34](https://github.com/ganesh47/cstack/issues/34) Validation intelligence +3. [#37](https://github.com/ganesh47/cstack/issues/37) Delivery checklist and deployment evidence +4. [#35](https://github.com/ganesh47/cstack/issues/35) Post-ship feedback +5. [#36](https://github.com/ganesh47/cstack/issues/36) Initiative graph and run control plane +6. [#38](https://github.com/ganesh47/cstack/issues/38) Capability-pack governance + +## Release Rule + +No workstream should update the active shipped spec until its implementation lands and the README, tests, and `docs/specs/cstack-spec-v0.1.md` are aligned. diff --git a/docs/research/cstack-workstream-kickoff-tracker.md b/docs/research/cstack-workstream-kickoff-tracker.md new file mode 100644 index 0000000..6587f8a --- /dev/null +++ b/docs/research/cstack-workstream-kickoff-tracker.md @@ -0,0 +1,307 @@ +# Autonomous Workstream Kickoff Tracker + +Historical note: + +- This document is a forward execution tracker, not part of the active shipped contract. +- It turns umbrella issue [#32](https://github.com/ganesh47/cstack/issues/32) and workstream issues [#33](https://github.com/ganesh47/cstack/issues/33) through [#38](https://github.com/ganesh47/cstack/issues/38) into implementation-ready autonomous threads. + +## Umbrella + +- umbrella issue: [#32](https://github.com/ganesh47/cstack/issues/32) +- child issues: + - [#33](https://github.com/ganesh47/cstack/issues/33) GitHub planning lineage + - [#34](https://github.com/ganesh47/cstack/issues/34) Validation intelligence + - [#35](https://github.com/ganesh47/cstack/issues/35) Post-ship feedback + - [#36](https://github.com/ganesh47/cstack/issues/36) Initiative graph and run control plane + - [#37](https://github.com/ganesh47/cstack/issues/37) Delivery checklist and deployment evidence + - [#38](https://github.com/ganesh47/cstack/issues/38) Capability-pack governance + +## Workstream [#33](https://github.com/ganesh47/cstack/issues/33): GitHub Planning Lineage + +### Impacted files and modules + +- `src/types.ts` + - extend `RunRecord.inputs` + - extend `RunLedgerEntry` + - extend `RunInspection` + - add issue-lineage and issue-draft record types +- `src/commands/spec.ts` + - add `--issue ` support + - write issue draft and issue-lineage artifacts +- `src/commands/discover.ts` + - add optional issue linkage for upstream planning runs +- `src/run.ts` + - surface linked issue in ledger entries +- `src/inspector.ts` + - load and render issue-lineage artifacts + - add `show issue-lineage` or equivalent summary output +- `src/github.ts` + - reuse existing issue parsing and issue state collection logic instead of duplicating GitHub lookups +- tests: + - `test/spec.test.ts` + - `test/inspect.test.ts` + - `test/runs.test.ts` + - likely `test/deliver.test.ts` or `test/ship.test.ts` for downstream lineage continuity + +### First independently shippable slice + +- add `--issue ` to `spec` +- persist linked issue numbers into `run.json` +- synthesize `artifacts/issue-draft.md` from the spec output +- synthesize `artifacts/issue-lineage.json` with linked issue, source run, and placeholder downstream PR/release slots +- show linked issue and issue draft presence in `inspect` + +This slice should not require GitHub mutation. It is useful even if the issue already exists and the user only wants durable linkage plus a draft body. + +### Key risks and non-goals + +- do not make GitHub the durable source of truth; the run directory stays primary +- do not broaden the first slice into initiative graph semantics +- do not require `discover`, `spec`, and `deliver` all to ship together; `spec` linkage is enough for v1 +- avoid duplicating GitHub issue parsing already present in `src/github.ts` + +### Tests to add + +- `spec --issue 123` writes issue-linked run metadata and issue artifacts +- linked issue flows through `inspect` +- ledger JSON includes linked issue numbers when present +- no GitHub access required for local issue-draft generation + +## Workstream [#34](https://github.com/ganesh47/cstack/issues/34): Validation Intelligence + +### Impacted files and modules + +- `src/validation.ts` + - primary implementation surface + - repo profiling, tool research, local validation records, coverage summaries +- `src/deliver.ts` + - validation stage wiring and failure handling +- `src/prompt.ts` + - validation lead and specialist prompt contracts +- `src/inspector.ts` + - `show validation`, `show pyramid`, `show coverage`, `show ci-validation`, `show tool-research` +- `src/config.ts` + - validation config validation and defaults +- tests: + - `test/validation.test.ts` + - `test/deliver.test.ts` + - `test/inspect.test.ts` + +### First independently shippable slice + +- strengthen the existing validation stage by making workspace-aware validation selection first-class in the saved plan +- improve `validation-plan.json` and `local-validation.json` to distinguish: + - root-native commands + - workspace-specific inventory-only targets + - deferred validation layers +- surface those distinctions in `inspect` + +This stays inside `deliver` and does not require new top-level CLI surface. + +### Key risks and non-goals + +- do not attempt universal auto-generation of tests across all ecosystems in the first slice +- keep local and CI parity visible even when incomplete +- avoid hidden mutation of CI workflows without explicit artifact evidence +- keep failure summaries separated from build failure, which is already handled in `src/deliver.ts` + +### Tests to add + +- mixed-workspace repo yields a validation plan with explicit support levels +- inspector shows deferred versus supported layers clearly +- validation failure remains distinguishable from build failure +- tool research output remains deterministic for existing fixtures + +## Workstream [#35](https://github.com/ganesh47/cstack/issues/35): Post-Ship Feedback + +### Impacted files and modules + +- `src/types.ts` + - add post-ship evidence, status, and follow-up draft types + - extend `RunInspection` +- `src/ship.ts` + - write post-ship summary and evidence placeholders for standalone ship runs +- `src/deliver.ts` + - write top-level post-ship artifacts after ship evidence is available +- `src/github.ts` + - reuse existing issue, checks, actions, and release evidence already collected +- `src/inspector.ts` + - load and render post-ship artifacts + - add `show post-ship` or equivalent +- tests: + - `test/ship.test.ts` + - `test/deliver.test.ts` + - `test/inspect.test.ts` + +### First independently shippable slice + +- add a passive post-ship artifact family: + - `artifacts/post-ship-summary.md` + - `artifacts/post-ship-evidence.json` + - `artifacts/follow-up-draft.md` +- populate those artifacts purely from existing ship and GitHub evidence: + - linked issue state + - required checks state + - release evidence + - security gate state +- generate follow-up recommendations without mutating deployments or observability systems + +### Key risks and non-goals + +- do not attempt continuous monitoring +- do not add deployment orchestration +- keep observed evidence separate from inferred follow-up recommendations +- avoid coupling the first slice to initiative graph semantics + +### Tests to add + +- ready ship run still writes stable post-ship artifacts +- blocked ship run produces follow-up recommendations instead of silent failure +- inspector can render post-ship evidence from saved artifacts +- deliver writes the same post-ship artifact family at the top level + +## Workstream [#36](https://github.com/ganesh47/cstack/issues/36): Initiative Graph And Run Control Plane + +### Impacted files and modules + +- `src/types.ts` + - extend `RunRecord.inputs` with initiative metadata + - extend `RunLedgerEntry` + - add initiative graph record types +- `src/run.ts` + - derive initiative-aware ledger entries +- `src/commands/runs.ts` + - add filtering or grouping by initiative +- `src/inspector.ts` + - load initiative graph artifacts + - add grouped lineage summary in the inspection view +- likely `src/commands/spec.ts`, `src/commands/build.ts`, `src/commands/ship.ts`, `src/commands/deliver.ts` + - accept initiative identifiers and persist them in runs +- tests: + - `test/runs.test.ts` + - `test/inspect.test.ts` + - targeted command tests for one initiating workflow such as `spec` + +### First independently shippable slice + +- add optional `initiativeId` and `initiativeTitle` to one planning workflow first, ideally `spec` +- persist `artifacts/initiative-graph.json` and `artifacts/initiative-summary.md` +- extend `runs --json` and `inspect` to show initiative linkage +- support grouping only across local run metadata in v1 + +### Key risks and non-goals + +- do not build a remote state service +- do not require every run to belong to an initiative +- do not broaden the first slice into issue mutation or post-ship follow-up generation +- keep grouping artifact-derived and rebuildable from local state + +### Tests to add + +- ledger filtering or grouping by initiative +- inspect summary shows initiative id and related local runs +- malformed initiative graph artifacts degrade gracefully +- runs without initiative metadata still render exactly as before + +## Workstream [#37](https://github.com/ganesh47/cstack/issues/37): Delivery Checklist And Deployment Evidence + +### Impacted files and modules + +- `src/types.ts` + - extend `DeliverShipRecord` + - extend `GitHubDeliveryRecord` or add readiness-policy/deployment-evidence records + - extend `RunInspection` +- `src/ship.ts` + - write explicit readiness-policy and deployment-evidence artifacts +- `src/deliver.ts` + - propagate those artifacts into deliver top-level and stage-local outputs +- `src/github.ts` + - reuse existing checks, issue, action, release, and security evidence to evaluate explicit readiness dimensions +- `src/config.ts` + - add optional policy flags for deployment evidence requirements +- `src/inspector.ts` + - render readiness dimensions and deployment evidence +- tests: + - `test/ship.test.ts` + - `test/deliver.test.ts` + - `test/inspect.test.ts` + - possibly `test/config.test.ts` + +### First independently shippable slice + +- keep the current ship and deliver control flow +- add two new artifacts: + - `artifacts/readiness-policy.json` + - `artifacts/deployment-evidence.json` +- classify readiness dimensions explicitly: + - review + - checks + - issues + - actions + - release + - deployment evidence +- deployment evidence may be reference-only in v1, for example a URL, tag, environment name, or artifact note + +### Key risks and non-goals + +- do not run deployments +- do not force deployment evidence for repos that do not need it +- avoid duplicating summaries already present in `github-delivery.json` +- keep blocker classification stable and inspectable + +### Tests to add + +- ship run writes readiness-policy and deployment-evidence artifacts +- deliver surfaces missing deployment evidence as a distinct blocker when policy requires it +- inspect can show deployment evidence and readiness dimensions +- repos without deployment-evidence requirements remain backward compatible + +## Workstream [#38](https://github.com/ganesh47/cstack/issues/38): Capability-Pack Governance + +### Impacted files and modules + +- `src/types.ts` + - extend `WorkflowConfig` + - add capability governance record types + - extend `RunInspection` +- `src/config.ts` + - parse and validate workflow capability config +- `src/prompt.ts` + - include allowed/requested capability context in workflow prompts +- `src/discover.ts` + - generalize the existing discover-only requested/available capability recording +- `src/validation.ts` + - optionally record capability usage for validation specialists and GitHub/browser use +- `src/inspector.ts` + - add capability-policy rendering +- tests: + - `test/config.test.ts` + - `test/prompt.test.ts` + - `test/discover.test.ts` + - `test/inspect.test.ts` + +### First independently shippable slice + +- add optional capability config to `discover` and `deliver` first +- write `artifacts/capabilities.json` and `artifacts/capability-policy.md` +- record: + - allowed packs + - requested packs + - available packs + - used packs + - downgraded packs +- surface discover web-research policy through the same generalized contract rather than a discover-only special case + +### Key risks and non-goals + +- do not turn capability packs into unbounded prompt assembly +- do not assume capability availability is identical across environments +- keep silent fallback out of scope; denied or downgraded capability use must be visible +- avoid broad schema rollout across every workflow in the first slice + +### Tests to add + +- config validation for capability allowlists +- prompt context includes capability policy +- discover run records downgraded web capability when policy disables it +- inspect renders requested versus available versus used capabilities diff --git a/docs/specs/cstack-capability-pack-governance-slice.md b/docs/specs/cstack-capability-pack-governance-slice.md new file mode 100644 index 0000000..dd06943 --- /dev/null +++ b/docs/specs/cstack-capability-pack-governance-slice.md @@ -0,0 +1,167 @@ +# Capability-Pack Governance Slice + +Historical note: + +- This document is a future slice spec, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- This slice defines how workflow-attached capabilities should become explicit, policy-visible, and inspectable. + +## Thesis + +`cstack` should expose capability packs as a first-class policy surface so workflows can declare which external research, browser, GitHub, shell, or other capability families are allowed, requested, available, and actually used. + +## Why This Slice Exists + +The current product already implies capability boundaries through workflow design and local tooling. + +What is still missing is a stable contract for: + +- which capability packs a workflow may attach +- which packs were requested versus actually available +- when external research or browser use was allowed by policy +- how capability usage is made inspectable after the run + +## Product Decision + +This slice turns capability-pack governance into explicit config, artifact, and inspection surfaces. + +It should strengthen workflow control without turning capability attachment into prompt soup. + +## Scope + +This slice owns: + +- workflow-level capability allowlists +- config for requested versus allowed capability packs +- artifact recording for requested, available, and used capabilities +- external research and browser-use policy visibility +- inspector views for capability usage + +This slice does not own: + +- issue lineage semantics +- validation strategy design +- initiative graph UX + +## Independent Team Contract + +The capability-pack governance team is responsible for: + +- capability config schema +- workflow attachment rules +- requested-versus-observed capability recording +- inspector support for capability visibility + +The team is explicitly not responsible for: + +- inventing new workflows solely to justify capability packs +- embedding arbitrary role files into prompts +- broad autonomous orchestration semantics + +## Capability Model + +Recommended capability families: + +- `shell` +- `web` +- `github` +- `browser` +- `mcp:` +- `skill:` + +Each workflow should be able to record: + +- allowed packs +- requested packs +- available packs +- used packs +- downgraded or denied packs + +## Config Contract + +Recommended future config shape: + +```toml +[workflows.discover.capabilities] +allowed = ["shell", "web", "github"] +defaultRequested = ["shell"] + +[workflows.deliver.capabilities] +allowed = ["shell", "github", "browser"] +defaultRequested = ["shell", "github"] +``` + +## Artifact Contract + +Recommended additions: + +- `artifacts/capabilities.json` +- `artifacts/capability-policy.md` + +Recommended meanings: + +- `capabilities.json` + - requested, allowed, available, used, and denied capability packs for the run +- `capability-policy.md` + - human-readable explanation of workflow capability posture and downgrades + +Recommended `capabilities.json` shape: + +```json +{ + "workflow": "discover", + "allowed": ["shell", "web", "github"], + "requested": ["shell", "web"], + "available": ["shell"], + "used": ["shell"], + "downgraded": [ + { + "name": "web", + "reason": "disabled by repo policy" + } + ] +} +``` + +## Inspector and Ledger Expectations + +`inspect` should be able to show: + +- which capabilities were requested +- which were actually available +- which were used +- which were downgraded or denied and why + +`runs` may later expose coarse capability summaries for filtering and audit. + +## Acceptance Criteria + +This slice is complete when: + +- workflows can declare allowed capability packs +- run artifacts show requested, available, and actually used capabilities +- external research is policy-visible rather than implicit +- capability usage remains bounded and inspectable + +## Release Boundary + +First release for this slice should include: + +- capability config schema +- workflow policy integration +- capability artifacts +- inspector support for capability visibility + +It should not require: + +- initiative graph support +- issue lineage support +- validation strategy support + +## Non-Goals + +This slice should not: + +- encourage unconstrained prompt assembly +- imply that capability availability is identical across environments +- hide downgraded capabilities behind silent fallback diff --git a/docs/specs/cstack-deliver-validation-intelligence-slice.md b/docs/specs/cstack-deliver-validation-intelligence-slice.md index c084272..15c3ebe 100644 --- a/docs/specs/cstack-deliver-validation-intelligence-slice.md +++ b/docs/specs/cstack-deliver-validation-intelligence-slice.md @@ -44,6 +44,23 @@ It is a bounded multi-agent stage that: - runs the validation pyramid - records coverage, gaps, and CI portability +## Independent Team Contract + +The validation intelligence team is responsible for: + +- repo and product-surface profiling for validation +- validation-plan generation +- test pyramid intent and coverage reporting +- local and GitHub Actions validation linkage +- validation-aware artifact and inspector contracts + +The team is explicitly not responsible for: + +- issue lineage design +- deployment evidence collection +- post-ship feedback capture +- initiative-level grouping + ## Scope This slice should cover: @@ -405,6 +422,19 @@ Recommended artifact meanings: - `local-validation.json` - developer-local commands and prerequisites +## Inspector And Ledger Expectations + +`inspect` should be able to show: + +- repo profile summary +- selected validation layers +- tool research and selected tool families +- local and CI validation plans +- coverage summary and residual gaps +- whether the stage concluded `ready`, `partial`, or `blocked` + +`runs` does not need a new top-level workflow for this slice, but deliver summaries should surface validation disposition clearly enough to distinguish build failure from validation failure. + ## Validation Record Shape Recommended `validation-plan.json` shape: @@ -512,6 +542,29 @@ In every non-ready case, artifacts must explain: - what remains - what manual follow-up is required +## Acceptance Criteria + +This slice is complete when: + +- `deliver` can infer a validation strategy appropriate to the repo shape +- validation artifacts explain what was run, what coverage was intended, and what remains uncovered +- local and GitHub Actions validation evidence are linked in one run +- failure summaries distinguish implementation failure from validation failure + +## Release Boundary + +First release for this slice should include: + +- an expanded internal `validation` stage in `deliver` +- richer `stages/validation/...` artifacts +- validation-aware inspector views + +It should not require: + +- issue lineage support +- deployment evidence support +- post-ship feedback support + ## Config Contract Recommended future config additions: diff --git a/docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md b/docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md new file mode 100644 index 0000000..b91acc7 --- /dev/null +++ b/docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md @@ -0,0 +1,168 @@ +# Delivery Checklist And Deployment Evidence Slice + +Historical note: + +- This document is a future slice spec, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- This slice defines how `ship` and `deliver` should grow a stronger final-delivery readiness contract. + +## Thesis + +`ship` and `deliver` should evaluate a stronger repo-aware readiness contract that includes deployment evidence references, explicit checklist policy, and clearer blocker classification without making `cstack` responsible for running deployments. + +## Why This Slice Exists + +Current `ship` and `deliver` already record: + +- ship summaries +- release checklists +- unresolved blockers +- GitHub delivery evidence + +What remains missing is a more explicit and inspectable final-delivery contract: + +- which environments or deployment targets matter +- which evidence is required for handoff +- which checks or approvals are required by policy +- why a run is ready, blocked, or partial + +## Product Decision + +This slice strengthens readiness evaluation and artifact quality around final delivery. + +It explicitly stops short of: + +- deployment orchestration +- environment mutation +- remote release execution beyond what current ship or deliver policy already allows + +## Scope + +This slice owns: + +- delivery checklist policy +- deployment evidence references +- required checks and approvals classification +- unresolved blocker taxonomy +- richer handoff artifacts for ship and deliver + +This slice does not own: + +- issue synthesis +- post-ship remediation generation beyond emitting handoff-ready data +- validation strategy design + +## Independent Team Contract + +The delivery checklist team is responsible for: + +- readiness policy model +- deployment evidence references +- upgraded `ship` and `deliver` artifacts +- inspector views for final-delivery evidence and blockers + +The team is explicitly not responsible for: + +- executing deployments +- changing GitHub planning lineage +- initiative-level grouping logic + +## Readiness Model + +The first release should support explicit readiness dimensions such as: + +- source change complete +- validation evidence complete +- review outcome acceptable +- required checks complete +- linked issue state acceptable +- release evidence complete when `--release` is active +- deployment evidence referenced when repo policy requires it + +Recommended readiness outcomes: + +- `ready` +- `changes-requested` +- `blocked` +- `partial` + +## Artifact Contract + +Recommended upgrades: + +- `artifacts/release-checklist.md` +- `artifacts/ship-record.json` +- `artifacts/github-delivery.json` +- `artifacts/delivery-report.md` + +Recommended additions: + +- `artifacts/deployment-evidence.json` +- `artifacts/readiness-policy.json` + +Recommended meanings: + +- `deployment-evidence.json` + - references to environment, deployment, or release evidence relevant to final handoff +- `readiness-policy.json` + - normalized checklist requirements and evaluated status for the run + +Recommended `readiness-policy.json` shape: + +```json +{ + "mode": "release", + "requirements": [ + { + "name": "required-checks", + "status": "satisfied" + }, + { + "name": "deployment-evidence", + "status": "missing" + } + ], + "overallStatus": "blocked" +} +``` + +## Inspector Expectations + +`inspect` should be able to show: + +- readiness dimensions +- unresolved blockers by category +- deployment evidence references +- whether readiness was blocked by missing evidence, failed checks, or policy mismatch + +## Acceptance Criteria + +This slice is complete when: + +- `ship` and `deliver` can express a repo-aware readiness contract +- deployment evidence can be referenced and inspected without `cstack` owning deployment execution +- unresolved blockers are classified clearly +- final artifacts make handoff quality auditable + +## Release Boundary + +First release for this slice should include: + +- upgraded `ship` and `deliver` readiness artifacts +- deployment evidence references +- explicit readiness-policy evaluation +- inspector support for final-delivery evidence and blockers + +It should not require: + +- deployment automation +- post-ship feedback support +- initiative graph support + +## Non-Goals + +This slice should not: + +- claim that referencing deployment evidence equals executing deployments +- force environment-specific policy on repos that do not need it +- hide missing evidence inside vague readiness summaries diff --git a/docs/specs/cstack-end-to-end-workstreams-spec.md b/docs/specs/cstack-end-to-end-workstreams-spec.md new file mode 100644 index 0000000..3f8a68e --- /dev/null +++ b/docs/specs/cstack-end-to-end-workstreams-spec.md @@ -0,0 +1,166 @@ +# End-to-End Product Delivery Workstreams + +Historical note: + +- This document is a forward-looking decomposition spec, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- The purpose of this document is to split future `cstack` expansion into independently shippable workstreams with clear team ownership, artifact boundaries, and evaluation rules. + +## Thesis + +`cstack` should extend from engineering-complete delivery toward a stronger end-to-end product delivery loop by adding independent workstreams that can each ship behind their own workflow, artifact, and inspection boundary. + +The intended boundary for this expansion is: + +1. intent +2. planning +3. implementation +4. validation +5. review +6. ship +7. post-ship feedback + +This umbrella does not include GTM execution, broad product operations, or long-running remote agent orchestration as part of the required contract. + +## Why Split Into Workstreams + +The current product already ships: + +- `discover`, `spec`, `build`, `review`, `ship`, and `deliver` +- durable run artifacts and lineage +- GitHub-aware delivery +- bounded delegation and specialist review +- artifact-grounded inspection + +The missing gap is not one monolithic feature. It is a set of adjacent control-plane capabilities that should be designed, implemented, evaluated, and released independently. + +## Workstream Set + +Recommended execution order: + +1. GitHub planning lineage +2. Validation intelligence +3. Post-ship feedback +4. Initiative graph and run control plane +5. Delivery checklist and deployment evidence +6. Capability-pack governance + +Each workstream must satisfy three conditions: + +- first useful release is independently shippable +- artifact contracts are explicit +- inspector and ledger impact is defined + +## Workstream Index + +### 1. GitHub planning lineage + +Goal: + +- make GitHub part of the planning control plane, not only the delivery control plane + +Primary doc: + +- `docs/specs/cstack-github-planning-lineage-slice.md` + +### 2. Validation intelligence + +Goal: + +- upgrade `deliver` validation into a first-class repo-aware validation system + +Primary doc: + +- `docs/specs/cstack-deliver-validation-intelligence-slice.md` + +### 3. Post-ship feedback + +Goal: + +- capture bounded post-ship evidence and follow-up generation without entering GTM operations + +Primary doc: + +- `docs/specs/cstack-post-ship-feedback-slice.md` + +### 4. Initiative graph and run control plane + +Goal: + +- lift lineage from single-run state into initiative-level orchestration across runs, issues, PRs, and follow-ups + +Primary doc: + +- `docs/specs/cstack-initiative-graph-slice.md` + +### 5. Delivery checklist and deployment evidence + +Goal: + +- strengthen final-delivery readiness through explicit deployment evidence references and repo-aware checklist policy + +Primary doc: + +- `docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md` + +### 6. Capability-pack governance + +Goal: + +- make workflow-attached capabilities explicit, policy-visible, and inspectable + +Primary doc: + +- `docs/specs/cstack-capability-pack-governance-slice.md` + +## Shared Operating Rules + +All workstreams must preserve the current product posture: + +- local-first artifacts remain the durable source of truth +- `cstack` records requested behavior separately from observed behavior +- no workstream claims shipped scope in the active spec until implementation lands +- every new artifact family must be visible in `inspect` +- every new grouping or lineage surface must be visible in `runs` or an equivalent ledger view + +## Evaluation Model + +Each workstream should be evaluated independently on: + +### Product value + +- does it close a real gap in the current contract + +### Operational independence + +- can it ship without another workstream being complete + +### Inspection quality + +- can a user reconstruct what happened from artifacts alone + +## Release Gates + +A workstream is release-ready when: + +1. artifact schema is defined +2. ledger and inspector implications are defined +3. acceptance criteria are testable +4. non-goals are explicit +5. integration points with other workstreams are additive rather than blocking + +## GitHub Spec Issue + +The corresponding GitHub issue draft for this umbrella lives at: + +- `docs/research/cstack-end-to-end-product-delivery-issue-draft.md` +- `docs/research/cstack-workstream-kickoff-tracker.md` + +That issue should build on: + +- [#1](https://github.com/ganesh47/cstack/issues/1) +- [#2](https://github.com/ganesh47/cstack/issues/2) +- [#3](https://github.com/ganesh47/cstack/issues/3) +- [#4](https://github.com/ganesh47/cstack/issues/4) + +without reopening their shipped baseline scope. diff --git a/docs/specs/cstack-github-planning-lineage-slice.md b/docs/specs/cstack-github-planning-lineage-slice.md new file mode 100644 index 0000000..5a89f46 --- /dev/null +++ b/docs/specs/cstack-github-planning-lineage-slice.md @@ -0,0 +1,173 @@ +# GitHub Planning Lineage Slice + +Historical note: + +- This document is a future slice spec, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- This slice defines how GitHub should become part of the planning control plane for `cstack`. + +## Thesis + +`cstack` should treat GitHub issues as first-class planning anchors that can be linked to `discover`, `spec`, `build`, `review`, `ship`, and `deliver` runs through durable local artifacts. + +## Why This Slice Exists + +Current `cstack` already uses GitHub as part of delivery: + +- issue linkage during ship and deliver +- PR and release evidence +- GitHub-aware readiness evaluation + +What is still missing is planning lineage: + +- explicit run-to-issue relationships +- issue-aware spec synthesis +- inspector views that show issue, PR, and release lineage together +- artifact contracts that let the user reconstruct the planning graph locally + +## Product Decision + +This slice makes GitHub part of the planning control plane without making GitHub the durable source of truth. + +The durable source of truth remains the local run directory. GitHub linkage is recorded as referenced external state. + +## Scope + +This slice owns: + +- issue-to-run linkage in run metadata +- spec issue synthesis from saved planning artifacts +- issue references inside run artifacts +- issue and PR lineage views in `inspect` +- local artifact schemas for issue linkage and planning lineage + +This slice does not own: + +- deployment evidence +- post-ship monitoring loops +- validation strategy generation + +## Independent Team Contract + +The GitHub planning lineage team is responsible for: + +- run metadata additions for issue linkage +- issue-aware `spec` and planning outputs +- artifact storage for linked issue state +- inspector support for upstream and downstream GitHub planning lineage + +The team is explicitly not responsible for: + +- introducing a new deployment workflow +- changing the validation stage logic +- building initiative-level grouping beyond issue-scoped lineage + +## Command and Workflow Impact + +Likely future surface: + +- `spec --issue ` +- `discover --issue ` +- `deliver --issue ` continues to exist, but records richer linkage +- optional issue synthesis or issue-update step from `spec` + +Hard rule: + +- any GitHub write must remain optional and policy-driven +- the local run must still be useful even when GitHub mutation is disabled + +## Artifact Contract + +Recommended additions: + +- `artifacts/github-planning.json` +- `artifacts/issue-draft.md` +- `artifacts/issue-lineage.json` + +Recommended meanings: + +- `github-planning.json` + - normalized issue linkage and planning state observed or requested for the run +- `issue-draft.md` + - proposed issue body synthesized from `discover` or `spec` artifacts +- `issue-lineage.json` + - issue, run, PR, and release references connected to this run + +Recommended `issue-lineage.json` shape: + +```json +{ + "linkedIssue": { + "number": 123, + "repo": "ganesh47/cstack", + "state": "open" + }, + "sourceRuns": [ + { + "runId": "discover-20260327-120000", + "workflow": "discover" + } + ], + "derivedRuns": [ + { + "runId": "spec-20260327-121500", + "workflow": "spec" + } + ], + "pullRequests": [ + { + "number": 45, + "state": "open" + } + ], + "releases": [ + { + "tag": "v0.18.0", + "state": "draft" + } + ] +} +``` + +## Inspector and Ledger Expectations + +`inspect` should be able to show: + +- linked issue +- source planning runs +- downstream PR and release references +- whether issue linkage was requested, observed, created, updated, or unavailable + +`runs` should be able to filter or summarize by linked issue when this slice ships. + +## Acceptance Criteria + +This slice is complete when: + +- a run can be explicitly tied to a GitHub issue +- `spec` can emit a structured issue draft artifact +- `inspect` can show upstream and downstream issue and PR lineage +- all issue lineage is reconstructable from local artifacts alone + +## Release Boundary + +First release for this slice should include: + +- issue-linked run metadata +- issue draft synthesis capability +- issue-aware inspector support + +It should not require: + +- post-ship feedback support +- initiative graph support +- deployment evidence support + +## Non-Goals + +This slice should not: + +- make GitHub the only planning surface +- require issue mutation for local planning workflows +- hide GitHub API gaps behind fake certainty +- introduce broad project-management abstractions beyond issue-scoped lineage diff --git a/docs/specs/cstack-initiative-graph-slice.md b/docs/specs/cstack-initiative-graph-slice.md new file mode 100644 index 0000000..745d7dd --- /dev/null +++ b/docs/specs/cstack-initiative-graph-slice.md @@ -0,0 +1,166 @@ +# Initiative Graph And Run Control Plane Slice + +Historical note: + +- This document is a future slice spec, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- This slice defines how `cstack` should move from single-run lineage into initiative-level grouping and inspection. + +## Thesis + +`cstack` should support a higher-level initiative graph that groups issues, runs, pull requests, releases, mitigations, and follow-up work into one local-first control plane without introducing a remote scheduler or database as the source of truth. + +## Why This Slice Exists + +Current lineage is strong inside one run and across direct parent-child run relationships. + +What remains missing is a durable answer to: + +- which runs belong to one initiative +- which PRs and issues are part of the same product effort +- how follow-up mitigations connect back to the originating work +- how operators inspect one initiative rather than one run at a time + +## Product Decision + +This slice adds initiative-level metadata and inspection while keeping run directories as the source of truth. + +The first release should use artifact-derived grouping, not a separate service or hidden state store. + +## Scope + +This slice owns: + +- initiative or epic identifiers in run metadata +- initiative graph artifacts +- grouped inspection over related runs, issues, PRs, releases, and follow-ups +- initiative-aware run ledger views +- parent-child work item linkage above the single-run level + +This slice does not own: + +- validation internals +- deployment evidence collection +- GitHub mutation semantics already handled by ship or deliver + +## Independent Team Contract + +The initiative graph team is responsible for: + +- initiative metadata model +- cross-run graph artifact model +- initiative-aware `runs` output +- grouped `inspect` surfaces + +The team is explicitly not responsible for: + +- adding new workflow stages +- changing review or ship policy +- replacing local artifacts with a persistent service + +## Metadata Model + +Recommended additions to run metadata: + +- `initiativeId` +- `initiativeTitle` +- `parentWorkItem` +- `relatedWorkItems` + +Recommended work item kinds: + +- `issue` +- `run` +- `pull-request` +- `release` +- `follow-up` +- `mitigation` + +## Artifact Contract + +Recommended additions: + +- `artifacts/initiative-graph.json` +- `artifacts/initiative-summary.md` + +Recommended meanings: + +- `initiative-graph.json` + - normalized relationship graph for the current initiative +- `initiative-summary.md` + - operator-readable summary of current initiative state, open branches, and remaining work + +Recommended `initiative-graph.json` shape: + +```json +{ + "initiative": { + "id": "initiative-identity-hardening", + "title": "Identity hardening" + }, + "nodes": [ + { + "kind": "issue", + "id": "issue-123", + "label": "#123" + }, + { + "kind": "run", + "id": "spec-20260327-120000", + "label": "spec" + } + ], + "edges": [ + { + "from": "issue-123", + "to": "spec-20260327-120000", + "type": "planned-by" + } + ] +} +``` + +## Ledger and Inspector Expectations + +`runs` should be able to: + +- group by initiative +- summarize initiative status +- show recent runs within one initiative + +`inspect` should be able to: + +- show the initiative summary +- show related issues, PRs, releases, and follow-ups +- explain how a mitigation or follow-up connects back to the originating initiative + +## Acceptance Criteria + +This slice is complete when: + +- multiple runs can be grouped under one initiative +- `runs` and `inspect` can surface initiative-level state +- initiative lineage remains artifact-derived and local-first +- follow-up and mitigation work can be connected back to the originating initiative + +## Release Boundary + +First release for this slice should include: + +- initiative metadata in runs +- initiative graph artifacts +- initiative-aware ledger and inspector views + +It should not require: + +- GitHub issue mutation +- post-ship feedback support +- capability-pack policy support + +## Non-Goals + +This slice should not: + +- become a generic PM tool +- require every run to belong to an initiative +- introduce opaque global state that cannot be rebuilt from artifacts diff --git a/docs/specs/cstack-post-ship-feedback-slice.md b/docs/specs/cstack-post-ship-feedback-slice.md new file mode 100644 index 0000000..04a382e --- /dev/null +++ b/docs/specs/cstack-post-ship-feedback-slice.md @@ -0,0 +1,167 @@ +# Post-Ship Feedback Slice + +Historical note: + +- This document is a future slice spec, not part of the active shipped contract. +- The active shipped contract remains `docs/specs/cstack-spec-v0.1.md`. +- This slice defines how `cstack` should grow from shipping readiness into bounded post-ship feedback capture. + +## Thesis + +`cstack` should extend past `ship` with a bounded post-ship feedback surface that records deployment-adjacent evidence, follow-up recommendations, and linked remediation work without turning into an operations platform. + +## Why This Slice Exists + +Current `ship` and `deliver` workflows can prove readiness and record delivery evidence. + +What is still missing is a structured answer to: + +- what happened after ship +- whether the shipped outcome triggered follow-up work +- which signals were observed versus inferred +- which new tasks should be created from those signals + +## Product Decision + +This slice adds a bounded post-ship phase or artifact family focused on evidence and follow-up generation. + +It does not add: + +- deployment orchestration +- incident response automation +- product analytics ownership +- customer support workflows + +## Scope + +This slice owns: + +- post-ship artifact schemas +- deployment status references +- release state, check state, and issue-reopen signal capture +- production error or rollback references when available +- follow-up issue or task draft generation +- inspector views for post-ship state + +This slice does not own: + +- running deployments +- deep observability integrations as a required baseline +- non-engineering post-launch operations + +## Independent Team Contract + +The post-ship feedback team is responsible for: + +- bounded evidence collection after `ship` +- clear observed-versus-inferred recording +- follow-up synthesis artifacts +- post-ship inspector views + +The team is explicitly not responsible for: + +- GitHub planning lineage +- validation strategy design +- initiative-wide grouping logic + +## Outcome Model + +A post-ship outcome should distinguish: + +- observed external evidence +- inferred impact or recommendation +- unresolved follow-up work +- whether new issues or tasks should be created + +Recommended statuses: + +- `stable` +- `watch` +- `follow-up-required` +- `rollback-observed` +- `signal-unavailable` + +## Artifact Contract + +Recommended additions: + +- `artifacts/post-ship-summary.md` +- `artifacts/post-ship-evidence.json` +- `artifacts/follow-up-draft.md` +- `artifacts/follow-up-lineage.json` + +Recommended meanings: + +- `post-ship-summary.md` + - human-readable summary of what happened after ship +- `post-ship-evidence.json` + - structured observed evidence references and classification +- `follow-up-draft.md` + - draft issue or task text created from post-ship signals +- `follow-up-lineage.json` + - linkage from shipped run to newly created or recommended follow-up work + +Recommended `post-ship-evidence.json` shape: + +```json +{ + "status": "follow-up-required", + "observedSignals": [ + { + "kind": "release-state", + "summary": "Release published successfully" + }, + { + "kind": "issue-reopened", + "summary": "Linked issue #123 was reopened after release" + } + ], + "inferredRecommendations": [ + "Create a follow-up investigation slice for regression in issue #123" + ], + "followUpRequired": true +} +``` + +## Inspector and Ledger Expectations + +`inspect` should be able to show: + +- post-ship status +- evidence references +- follow-up recommendations +- linked follow-up issue or task drafts + +`runs` should be able to surface whether a shipped run later accumulated post-ship follow-up state. + +## Acceptance Criteria + +This slice is complete when: + +- a shipped run can record post-ship evidence +- post-ship artifacts distinguish observed signals from inferred recommendations +- follow-up drafts can be generated from post-ship findings +- the slice stops at evidence and follow-up rather than operational execution + +## Release Boundary + +First release for this slice should include: + +- post-ship artifact schema +- post-ship summary and evidence capture +- follow-up draft generation +- inspector support for post-ship state + +It should not require: + +- deployment orchestration +- mandatory observability vendor integrations +- initiative graph support + +## Non-Goals + +This slice should not: + +- claim to monitor production continuously +- mutate external systems without explicit operator or policy control +- absorb support, sales, or GTM follow-up into the `cstack` workflow contract diff --git a/docs/specs/cstack-spec-v0.1.md b/docs/specs/cstack-spec-v0.1.md index 95f74b7..6e63b45 100644 --- a/docs/specs/cstack-spec-v0.1.md +++ b/docs/specs/cstack-spec-v0.1.md @@ -23,8 +23,8 @@ Current top-level commands: - `cstack ` - `cstack run [--dry-run]` -- `cstack discover ` -- `cstack spec [--from-run ]` +- `cstack discover [--issue ]` +- `cstack spec [--from-run ] [--issue ]` - `cstack build [--from-run ] [--exec] [--allow-dirty]` - `cstack review [--from-run ]` - `cstack ship [--from-run ] [--release] [--issue ] [--allow-dirty]` @@ -68,6 +68,7 @@ Execution model: Key artifacts: - `artifacts/findings.md` +- optional `artifacts/issue-lineage.json` - `stages/discover/research-plan.json` - `stages/discover/artifacts/discovery-report.md` - optional `stages/discover/delegates//request.md` @@ -89,12 +90,15 @@ Inputs: - direct prompt, or - `--from-run ` to link to an upstream artifact +- optional `--issue ` to bind or override the planning issue for downstream lineage Key artifacts: - `artifacts/spec.md` - `artifacts/plan.json` - `artifacts/open-questions.md` +- optional `artifacts/issue-draft.md` +- optional `artifacts/issue-lineage.json` - `final.md` ### `build` diff --git a/docs/workflows/current-project-workflow.md b/docs/workflows/current-project-workflow.md index 0299306..d855a36 100644 --- a/docs/workflows/current-project-workflow.md +++ b/docs/workflows/current-project-workflow.md @@ -17,8 +17,8 @@ Implemented commands: - `cstack ` - `cstack run [--dry-run]` -- `cstack discover ` -- `cstack spec [--from-run ]` +- `cstack discover [--issue ]` +- `cstack spec [--from-run ] [--issue ]` - `cstack build [--from-run ] [--exec] [--allow-dirty]` - `cstack review [--from-run ]` - `cstack ship [--from-run ] [--release] [--issue ] [--allow-dirty]` @@ -33,7 +33,8 @@ Implemented commands: Implemented behavior: - `discover` supports a bounded discover-team model with a Research Lead and optional delegated tracks -- `spec` emits `spec.md`, `plan.json`, and `open-questions.md` +- `discover --issue ` records planning issue lineage for later `spec` and `inspect` use +- `spec` emits `spec.md`, `plan.json`, `open-questions.md`, and issue-linkage artifacts when issue-linked - `build` records requested vs observed mode, session lineage, change summaries, and verification artifacts - `review` is a standalone critique workflow with verdict artifacts and bounded specialist reviewers - `ship` is a standalone GitHub-aware handoff and release-readiness workflow @@ -63,6 +64,7 @@ Recommended commands: ```bash # Explore unfamiliar areas cstack discover "Map the billing retry pipeline and release touchpoints" +cstack discover --issue 123 "Map the billing retry pipeline and release touchpoints" # Turn context into a plan cstack spec --from-run "Design the billing retry cleanup" @@ -198,3 +200,21 @@ then update all of: - tests The repo is healthy when the code, tests, README, and active spec describe the same product. + +## Forward-Looking Slice Specs + +The following documents are planning artifacts only. They are not part of the active shipped contract until implementation lands and the active spec is updated: + +- `docs/specs/cstack-end-to-end-workstreams-spec.md` +- `docs/specs/cstack-github-planning-lineage-slice.md` +- `docs/specs/cstack-deliver-validation-intelligence-slice.md` +- `docs/specs/cstack-post-ship-feedback-slice.md` +- `docs/specs/cstack-initiative-graph-slice.md` +- `docs/specs/cstack-delivery-checklist-deployment-evidence-slice.md` +- `docs/specs/cstack-capability-pack-governance-slice.md` +- `docs/research/cstack-end-to-end-product-delivery-issue-draft.md` +- `docs/research/cstack-workstream-kickoff-tracker.md` +- `docs/research/cstack-workstream-execution-tracker.md` +- `docs/research/cstack-end-to-end-workstream-tracker.md` +- `docs/research/cstack-workstream-kickoff-tracker.md` +- `docs/research/cstack-workstream-execution-tracker.md` diff --git a/src/blockers.ts b/src/blockers.ts new file mode 100644 index 0000000..6cc47a8 --- /dev/null +++ b/src/blockers.ts @@ -0,0 +1,90 @@ +import type { EnvironmentBlockerCategory } from "./types.js"; + +export interface ExecutionBlockerClassification { + category: EnvironmentBlockerCategory; + detail: string; +} + +function normalize(input: string): string { + return input.replace(/\s+/g, " ").trim(); +} + +function firstUsefulLine(input: string): string { + return ( + input + .split("\n") + .map((line) => line.trim()) + .find((line) => line && !/^session id:/i.test(line)) ?? "Command failed without a diagnostic line." + ); +} + +function commandLooksLikeRepoCheck(command: string): boolean { + return /\b(test|pytest|vitest|jest|mvn test|gradle test|cargo test|ruff|mypy|eslint|tsc|typecheck|lint|check)\b/i.test(command); +} + +export function classifyExecutionBlocker(command: string, output: string): ExecutionBlockerClassification | null { + const detail = firstUsefulLine(output); + const normalized = normalize(output); + if (!normalized) { + return null; + } + + if ( + /\b(command not found|no such file or directory|not recognized as an internal or external command|cannot find (?:the )?(?:file|module|executable)|executable file not found)\b/i.test( + normalized + ) + ) { + return { category: "host-tool-missing", detail }; + } + + if ( + /\b(no valid Docker environment|docker daemon|could not connect to the Docker daemon|permission denied while trying to connect to the Docker daemon|error while loading shared libraries: libdocker|docker: cannot connect to the Docker daemon)\b/i.test( + normalized + ) + ) { + return { category: "host-tool-missing", detail }; + } + + if ( + /\b(java version|unsupported major.minor|unsupported release|unsupported class file major version|no suitable java|python .* not found)\b/i.test(normalized) + ) { + return { category: "toolchain-mismatch", detail }; + } + + if ( + /\b(unsupportedclassversionerror|release version .* not supported|source option \d+ is no longer supported|target option \d+ is no longer supported|requires node >=|engine .* unsupported|requires python|java_home|python \d+\.\d+ not found|version mismatch|unsupported runtime)\b/i.test( + normalized + ) + ) { + return { category: "toolchain-mismatch", detail }; + } + + if ( + /\b(registry|npmjs\.org|pypi|repo\.maven|repo1\.maven|maven central|artifactory|package index|simple index|crates\.io)\b/i.test(normalized) && + /\b(enotfound|eai_again|econrefused|econnreset|network is unreachable|could not resolve|temporary failure|service unavailable|timed? out|tls|ssl)\b/i.test( + normalized + ) + ) { + return { category: "registry-unreachable", detail }; + } + + if ( + /\b(enotfound|eai_again|econrefused|econnreset|network is unreachable|failed to lookup address information|could not resolve host|temporary failure in name resolution|timed? out|tls handshake timeout)\b/i.test( + normalized + ) + ) { + return { category: "network-blocked", detail }; + } + + if (commandLooksLikeRepoCheck(command)) { + return { category: "repo-test-failure", detail }; + } + + return null; +} + +export function uniqueBlockerCategories( + categories: Array +): EnvironmentBlockerCategory[] { + return [...new Set(categories.filter((value): value is EnvironmentBlockerCategory => Boolean(value)))]; +} diff --git a/src/build.ts b/src/build.ts index 1373df9..9b7ad5c 100644 --- a/src/build.ts +++ b/src/build.ts @@ -4,6 +4,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { runCodexExec, runCodexInteractive } from "./codex.js"; import type { CodexRunResult } from "./codex.js"; +import { classifyExecutionBlocker, uniqueBlockerCategories } from "./blockers.js"; import { buildBuildPrompt } from "./prompt.js"; import { readRun } from "./run.js"; import type { @@ -14,6 +15,7 @@ import type { BuildVerificationCommandRecord, BuildVerificationRecord, CstackConfig, + EnvironmentBlockerCategory, RunRecord, WorkflowMode } from "./types.js"; @@ -32,6 +34,7 @@ const TRANSIENT_FAILURE_PATTERNS = [ /service unavailable/i, /\b5\d\d\b/ ] as const; +const BUILD_NO_PROGRESS_TIMEOUT_SECONDS = 120; function isInternalRunArtifactPath(filePath: string): boolean { const normalized = filePath.replace(/\\/g, "/").replace(/^\.?\//, ""); @@ -177,7 +180,9 @@ function renderRecoverySummary(diagnosis: BuildFailureDiagnosisRecord | null, at return [ "# Build Recovery Summary", "", - diagnosis ? `## Diagnosis\n- category: ${diagnosis.category}\n- summary: ${diagnosis.summary}` : "## Diagnosis\n- Build completed without recovery blockers.", + diagnosis + ? `## Diagnosis\n- category: ${diagnosis.category}\n${diagnosis.blockerCategory ? `- blocker: ${diagnosis.blockerCategory}\n` : ""}- summary: ${diagnosis.summary}` + : "## Diagnosis\n- Build completed without recovery blockers.", ...(diagnosis?.recommendedActions?.length ? ["", "## Recommended actions", ...diagnosis.recommendedActions.map((action) => `- ${action}`)] : []), @@ -490,7 +495,8 @@ function renderVerificationSummary(record: BuildVerificationRecord): string { if (record.status === "failed") { const failed = record.results.find((result) => result.status === "failed"); if (failed) { - return `${failed.command} failed with exit ${failed.exitCode}.`; + const blockerPrefix = failed.blockerCategory ? `${failed.blockerCategory}: ` : ""; + return `${blockerPrefix}${failed.command} failed with exit ${failed.exitCode}.`; } } return "Verification passed."; @@ -518,35 +524,60 @@ function classifyBuildFailure(options: { ]); const recommendedActions: string[] = []; let category: BuildFailureCategory = "unknown"; + let blockerCategory: EnvironmentBlockerCategory | undefined; let summary = "Build failed for an unspecified reason."; let detail = "cstack could not recover a higher-confidence cause from the build artifacts."; + const failedVerification = options.verificationRecord.results.find((result) => result.status === "failed"); + const inferredBlocker = + failedVerification?.blockerCategory + ? { category: failedVerification.blockerCategory, detail: failedVerification.blockerDetail ?? renderVerificationSummary(options.verificationRecord) } + : classifyExecutionBlocker("codex build", [options.stderrTail, options.finalBody, ...options.assessment.notes].join("\n")); + const missingRequiredTool = options.assessment.notes.find((note) => /Required tool not currently available/i.test(note)); const failedBootstrap = [...options.recoveryAttempts].reverse().find((attempt) => attempt.kind === "bootstrap" && attempt.status === "failed"); - if (options.result.timedOut && options.result.timeoutSeconds) { + if ((options.result.timedOut || options.result.stalled) && options.result.timeoutSeconds) { category = "timeout"; - summary = `Build timed out after ${options.result.timeoutSeconds}s before the implementation stage completed.`; + blockerCategory = "orchestration-timeout"; + summary = options.result.stalled + ? `Build stalled for ${options.result.timeoutSeconds}s before the implementation stage completed.` + : `Build timed out after ${options.result.timeoutSeconds}s before the implementation stage completed.`; detail = "The Codex-backed build exceeded the configured stage timeout, so downstream deliver stages were blocked."; recommendedActions.push("Increase the build timeout or narrow the requested implementation scope before rerunning."); } else if (failedBootstrap) { const combinedEvidence = (failedBootstrap.evidence ?? []).join("\n"); category = isTransientFailure(combinedEvidence) ? "transient-external" : "bootstrap-failure"; + blockerCategory = classifyExecutionBlocker(failedBootstrap.command ?? failedBootstrap.label, combinedEvidence)?.category; summary = failedBootstrap.summary; detail = `cstack attempted ${failedBootstrap.label} before rerunning build, but bootstrap did not complete successfully.`; recommendedActions.push("Inspect the recovery attempts artifact to see which bootstrap command failed."); if (category === "transient-external") { recommendedActions.push("Retry once network or registry access is stable."); } + } else if (missingRequiredTool) { + category = "missing-tool"; + blockerCategory = "host-tool-missing"; + summary = "Build failed because a required host tool was missing from the execution environment."; + detail = missingRequiredTool; + recommendedActions.push("Install or expose the missing tool in the execution environment, then rerun build."); } else if (options.verificationRecord.status === "failed") { category = "verification-failure"; + blockerCategory = failedVerification?.blockerCategory; summary = `Build completed, but verification failed: ${renderVerificationSummary(options.verificationRecord)}`; - detail = "The implementation stage produced output, but the requested verification commands did not all pass."; - recommendedActions.push("Fix the failing verification command and rerun build."); + detail = + failedVerification?.blockerDetail ?? + "The implementation stage produced output, but the requested verification commands did not all pass."; + recommendedActions.push( + blockerCategory && blockerCategory !== "repo-test-failure" + ? "Resolve the execution environment blocker, then rerun build." + : "Fix the failing verification command and rerun build." + ); } else if ( !options.sessionRecord.observability.sessionIdObserved && !options.sessionRecord.observability.transcriptObserved && !options.sessionRecord.observability.finalArtifactObserved ) { category = "codex-process-failure"; + blockerCategory = inferredBlocker?.category; summary = options.assessment.bootstrapActions.length > 0 ? `Build failed before Codex produced a usable session, even after ${options.assessment.bootstrapActions.length} bounded environment preparation step${options.assessment.bootstrapActions.length > 1 ? "s" : ""}.` @@ -557,12 +588,14 @@ function classifyBuildFailure(options: { recommendedActions.push("Retry with a narrower remediation prompt if the repo scope is very large."); } else if (/command not found|not found/i.test(options.stderrTail)) { category = "missing-tool"; + blockerCategory = "host-tool-missing"; summary = "Build failed because a required tool was missing during execution."; detail = `The captured build stderr suggests a missing command while operating on a repo that requires: ${options.assessment.requiredTools.join(", ") || "unknown tools"}.`; recommendedActions.push("Install or expose the missing tool in the execution environment, then rerun build."); } else { const firstErrorLine = firstMeaningfulErrorLine(options.stderrTail); category = "build-script-failure"; + blockerCategory = inferredBlocker?.category; summary = firstErrorLine ? `Build failed after Codex started work: ${truncateLine(firstErrorLine)}` : `Build failed with exit code ${options.result.code}${options.result.signal ? ` (${options.result.signal})` : ""}.`; @@ -576,6 +609,7 @@ function classifyBuildFailure(options: { return { category, + ...(blockerCategory ? { blockerCategory } : {}), summary, detail, evidence, @@ -637,7 +671,12 @@ function buildFallbackFinalBody(options: { "", "## Diagnosis", ...(options.diagnosis - ? [`- category: ${options.diagnosis.category}`, `- summary: ${options.diagnosis.summary}`, `- detail: ${options.diagnosis.detail}`] + ? [ + `- category: ${options.diagnosis.category}`, + ...(options.diagnosis.blockerCategory ? [`- blocker: ${options.diagnosis.blockerCategory}`] : []), + `- summary: ${options.diagnosis.summary}`, + `- detail: ${options.diagnosis.detail}` + ] : ["- Build completed without a classified failure diagnosis."]), "", "## Notes", @@ -702,13 +741,16 @@ async function runVerificationCommands( await fs.writeFile(stdoutPath, execError.stdout ?? "", "utf8"); await fs.writeFile(stderrPath, execError.stderr ?? execError.message, "utf8"); const exitCode = typeof execError.code === "number" ? execError.code : 1; + const blocker = classifyExecutionBlocker(command, `${execError.stderr ?? ""}\n${execError.stdout ?? ""}\n${execError.message ?? ""}`); results.push({ command, exitCode, status: "failed", durationMs: Date.now() - Date.parse(startedAt), stdoutPath, - stderrPath + stderrPath, + ...(blocker?.category ? { blockerCategory: blocker.category } : {}), + ...(blocker?.detail ? { blockerDetail: blocker.detail } : {}) }); attempts.push( buildAttemptRecord({ @@ -730,7 +772,8 @@ async function runVerificationCommands( record: { status: results.every((result) => result.status === "passed") ? "passed" : "failed", requestedCommands: commands, - results + results, + blockerCategories: uniqueBlockerCategories(results.map((result) => result.blockerCategory)) }, attempts }; @@ -749,6 +792,7 @@ async function runCodexBuildAttempt(options: { config: CstackConfig; runId: string; timeoutSeconds?: number; + noProgressTimeoutSeconds?: number; }): Promise { let observedMode = options.requestedMode; let fallbackReason: string | undefined; @@ -800,22 +844,24 @@ async function runCodexBuildAttempt(options: { stdoutPath: options.paths.stdoutPath, stderrPath: options.paths.stderrPath, config: options.config, - ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}) + ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}), + ...(typeof options.noProgressTimeoutSeconds === "number" ? { noProgressTimeoutSeconds: options.noProgressTimeoutSeconds } : {}) }); } } else { - result = await runCodexExec({ - cwd: options.executionCwd, - workflow: "build", - runId: options.runId, - prompt, + result = await runCodexExec({ + cwd: options.executionCwd, + workflow: "build", + runId: options.runId, + prompt, finalPath: options.paths.finalPath, eventsPath: options.paths.eventsPath, - stdoutPath: options.paths.stdoutPath, - stderrPath: options.paths.stderrPath, - config: options.config, - ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}) - }); + stdoutPath: options.paths.stdoutPath, + stderrPath: options.paths.stderrPath, + config: options.config, + ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}), + ...(typeof options.noProgressTimeoutSeconds === "number" ? { noProgressTimeoutSeconds: options.noProgressTimeoutSeconds } : {}) + }); } return { @@ -896,6 +942,11 @@ export async function runBuildExecution(options: BuildExecutionOptions): Promise ...(options.linkedContext?.run.workflow ? { linkedWorkflow: options.linkedContext.run.workflow } : {}) }); + const noProgressTimeoutSeconds = Math.min( + typeof options.timeoutSeconds === "number" && options.timeoutSeconds > 0 ? options.timeoutSeconds : BUILD_NO_PROGRESS_TIMEOUT_SECONDS, + BUILD_NO_PROGRESS_TIMEOUT_SECONDS + ); + let attemptNumber = 0; let codexOutcome: CodexAttemptOutcome | null = null; do { @@ -908,7 +959,8 @@ export async function runBuildExecution(options: BuildExecutionOptions): Promise paths: options.paths, config: options.config, runId: options.runId, - ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}) + ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}), + ...(typeof noProgressTimeoutSeconds === "number" ? { noProgressTimeoutSeconds } : {}) }); const retrying = shouldRetryCodexAttempt(codexOutcome, attemptNumber); recoveryAttempts.push( @@ -971,6 +1023,8 @@ export async function runBuildExecution(options: BuildExecutionOptions): Promise sessionIdObserved: Boolean(codexOutcome.result.sessionId), transcriptObserved: Boolean(codexOutcome.transcriptBody.trim()), finalArtifactObserved: Boolean(codexOutcome.finalBody.trim()), + ...(codexOutcome.result.stalled ? { stalled: true } : {}), + ...(codexOutcome.result.stallReason ? { stallReason: codexOutcome.result.stallReason } : {}), ...(codexOutcome.result.timedOut ? { timedOut: true } : {}), ...(codexOutcome.result.timeoutSeconds ? { timeoutSeconds: codexOutcome.result.timeoutSeconds } : {}), ...(codexOutcome.fallbackReason ? { fallbackReason: codexOutcome.fallbackReason } : {}) diff --git a/src/cli.ts b/src/cli.ts index ee9a600..4d7a583 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -18,17 +18,17 @@ function usage(): string { "Usage:", " cstack ", " cstack run [--dry-run]", - " cstack discover ", - " cstack spec ", - " cstack build [--from-run ] [--exec] [--allow-dirty]", - " cstack review [--from-run ]", - " cstack ship [--from-run ] [--release] [--issue ] [--allow-dirty]", - " cstack deliver [--from-run ] [--exec] [--release] [--issue ]", + " cstack discover [--issue ]", + " cstack spec [--issue ] [--initiative ] [--initiative-title ]", + " cstack build <prompt> [--from-run <run-id>] [--initiative <id>] [--initiative-title <title>] [--exec] [--allow-dirty]", + " cstack review <prompt> [--from-run <run-id>] [--initiative <id>] [--initiative-title <title>]", + " cstack ship <prompt> [--from-run <run-id>] [--initiative <id>] [--initiative-title <title>] [--release] [--issue <n>] [--allow-dirty]", + " cstack deliver <prompt> [--from-run <run-id>] [--initiative <id>] [--initiative-title <title>] [--exec] [--release] [--issue <n>]", " cstack rerun <run-id>", " cstack resume <run-id>", " cstack fork <run-id> [--workflow <name>]", " cstack update [--check] [--dry-run] [--yes] [--version <x>] [--channel stable]", - " cstack runs [--active] [--workflow <name>] [--status <status>] [--recent <n>] [--json]", + " cstack runs [--active] [--workflow <name>] [--status <status>] [--issue <n>] [--initiative <id>] [--recent <n>] [--json]", " cstack inspect [run-id] [--interactive]" ].join("\n"); } @@ -39,7 +39,7 @@ async function main(): Promise<void> { switch (command) { case "discover": - await runDiscover(cwd, rest.join(" ")); + await runDiscover(cwd, rest); return; case "run": await runIntentCommand(cwd, rest, "run"); diff --git a/src/codex.ts b/src/codex.ts index d2e739e..55622b2 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -19,6 +19,8 @@ export interface CodexRunOptions { config: CstackConfig; silentProgress?: boolean; timeoutSeconds?: number; + staleSessionTimeoutSeconds?: number; + noProgressTimeoutSeconds?: number; } export interface CodexRunResult { @@ -29,6 +31,10 @@ export interface CodexRunResult { lastActivity?: string; timedOut?: boolean; timeoutSeconds?: number; + stalled?: boolean; + stallReason?: string; + synthesizedFinalArtifact?: boolean; + synthesizedFinalReason?: string; } export interface CodexInteractiveRunOptions { @@ -71,6 +77,16 @@ function summarizeCommandLine(line: string): string | null { return `Tool ${toolMatch[2]}: ${command}`; } +function summarizeCommandStartLine(line: string): string | null { + const toolMatch = line.match(/^(.+?) in .+$/); + if (!toolMatch?.[1]) { + return null; + } + + const command = toolMatch[1].replace(/^\/bin\/(?:ba|z)sh -lc\s+/, "").trim(); + return command ? `Tool started: ${command}` : null; +} + function looksLikeCodeLine(trimmed: string): boolean { return ( /^(const|let|var|function|class|interface|type|import|export|return|await)\b/.test(trimmed) || @@ -85,41 +101,47 @@ export function summarizeActivityLine(line: string): string | null { if (!trimmed) { return null; } + const normalized = trimmed.replaceAll("’", "'"); - if (/^session id:/i.test(trimmed)) { + if (/^session id:/i.test(normalized)) { return null; } if ( /^(OpenAI Codex v|[-]{3,}|workdir:|model:|provider:|approval:|sandbox:|reasoning effort:|reasoning summaries:|user$)/i.test( - trimmed + normalized ) ) { return null; } - if (/^(#|##|###)\s/.test(trimmed)) { + if (/^(#|##|###)\s/.test(normalized)) { return null; } - if (looksLikeCodeLine(trimmed)) { + if (looksLikeCodeLine(normalized)) { return null; } - const commandSummary = summarizeCommandLine(trimmed); + const commandSummary = summarizeCommandLine(normalized); if (commandSummary) { return commandSummary; } - if (/^(mcp:|mcp startup:)/i.test(trimmed)) { + const commandStartSummary = summarizeCommandStartLine(normalized); + if (commandStartSummary) { + return commandStartSummary; + } + + if (/^(mcp:|mcp startup:)/i.test(normalized)) { return trimmed; } - if (/^(I('| a)m|I'm)\b/i.test(trimmed)) { + if (/^(I(?:'m| am))\b/i.test(normalized)) { return trimmed; } - if (/^(scanning|writing|thinking|reading|mapping|drafting|analyzing|summarizing|checking|reviewing|completed)\b/i.test(trimmed)) { + if (/^(scanning|writing|thinking|reading|mapping|drafting|analyzing|summarizing|checking|reviewing|completed)\b/i.test(normalized)) { return trimmed; } @@ -231,6 +253,62 @@ function appendPreview(preview: string, chunk: string, limit = 16_384): string { return combined.length <= limit ? combined : combined.slice(combined.length - limit); } +function extractFallbackArtifactBody(input: string): string { + const cleaned = stripCapturedControl(input).trim(); + if (!cleaned) { + return ""; + } + + const markdownIndex = cleaned.search(/^#\s+/m); + if (markdownIndex >= 0) { + return `${cleaned.slice(markdownIndex).trimEnd()}\n`; + } + + const jsonStart = cleaned.indexOf("{"); + const jsonEnd = cleaned.lastIndexOf("}"); + if (jsonStart >= 0 && jsonEnd > jsonStart) { + return `${cleaned.slice(jsonStart, jsonEnd + 1).trim()}\n`; + } + + return `${cleaned}\n`; +} + +async function ensureFinalArtifact(options: { + finalPath: string; + stdoutPreview: string; + stderrPreview: string; + exitCode: number; + lastActivity: string; +}): Promise<{ synthesized: boolean; reason?: string }> { + try { + const existing = await fs.readFile(path.resolve(options.finalPath), "utf8"); + if (existing.trim()) { + return { synthesized: false }; + } + } catch {} + + const recoveredBody = + extractFallbackArtifactBody(options.stdoutPreview) || + extractFallbackArtifactBody(options.stderrPreview) || + [ + "# Codex Fallback Artifact", + "", + `Codex exited with code ${options.exitCode} before writing the expected final artifact.`, + "", + `Last activity: ${options.lastActivity}` + ].join("\n") + "\n"; + + await fs.mkdir(path.dirname(path.resolve(options.finalPath)), { recursive: true }); + await fs.writeFile(path.resolve(options.finalPath), recoveredBody, "utf8"); + return { + synthesized: true, + reason: + recoveredBody.startsWith("# Codex Fallback Artifact") + ? "Codex exited without writing a final artifact; cstack synthesized a fallback artifact from execution status." + : "Codex exited without writing a final artifact; cstack synthesized a fallback artifact from captured output." + }; +} + function looksLikeCompletedPayload(input: string): boolean { const cleaned = stripCapturedControl(input).trim(); if (!cleaned) { @@ -244,6 +322,70 @@ function looksLikeCompletedPayload(input: string): boolean { ); } +async function readTextFromPath(filePath: string): Promise<string> { + try { + return await fs.readFile(path.resolve(filePath), "utf8"); + } catch { + return ""; + } +} + +async function tailTextFromPath(filePath: string, maxLines = 24): Promise<string> { + const body = await readTextFromPath(filePath); + if (!body.trim()) { + return ""; + } + return body + .trim() + .split("\n") + .slice(-maxLines) + .join("\n"); +} + +function compactSnippet(input: string, limit = 320): string { + const normalized = input.replace(/\s+/g, " ").trim(); + if (normalized.length <= limit) { + return normalized; + } + return `${normalized.slice(0, limit - 1)}...`; +} + +export async function readCodexFinalOutput(options: { + context: string; + finalPath: string; + stdoutPath: string; + stderrPath: string; + result: Pick<CodexRunResult, "code" | "signal" | "timedOut" | "timeoutSeconds" | "stalled" | "stallReason" | "lastActivity">; +}): Promise<string> { + const finalBody = await readTextFromPath(options.finalPath); + if (finalBody.trim()) { + return finalBody; + } + + const details: string[] = []; + if (options.result.timedOut && options.result.timeoutSeconds) { + details.push(`timed out after ${options.result.timeoutSeconds}s`); + } else { + details.push(`exit code ${options.result.code}${options.result.signal ? ` (${options.result.signal})` : ""}`); + } + if (options.result.stalled && options.result.stallReason) { + details.push(options.result.stallReason); + } + if (options.result.lastActivity) { + details.push(`last activity: ${options.result.lastActivity}`); + } + + const stderrTail = compactSnippet(await tailTextFromPath(options.stderrPath, 12)); + const stdoutTail = compactSnippet(await tailTextFromPath(options.stdoutPath, 12)); + if (stderrTail) { + details.push(`stderr tail: ${stderrTail}`); + } else if (stdoutTail) { + details.push(`stdout tail: ${stdoutTail}`); + } + + throw new Error(`${options.context} did not write final output. ${details.join(". ")}`); +} + async function appendEvent(eventsPath: string, event: RunEvent): Promise<void> { await fs.appendFile(path.resolve(eventsPath), `${JSON.stringify(event)}\n`, "utf8"); } @@ -346,13 +488,28 @@ export async function runCodexExec(options: CodexRunOptions): Promise<CodexRunRe let stderrPreview = ""; let lastHeartbeatAt = startedAt; let lastRawOutputAt = startedAt; + let sessionObservedAt: number | undefined; + let meaningfulActivitySinceSession = false; + let noProgressTimeoutMs = + typeof options.noProgressTimeoutSeconds === "number" && options.noProgressTimeoutSeconds > 0 + ? options.noProgressTimeoutSeconds * 1000 + : undefined; + let lastProgressMessage: string | undefined; + let lastProgressAt = startedAt; let timedOut = false; let stalledAfterOutput = false; + let stalledAfterSession = false; + let stalledAfterNoProgress = false; + let stallReason: string | undefined; let forcedSignal: NodeJS.Signals | null = null; let killTimer: NodeJS.Timeout | undefined; const completionStallMs = Number.parseInt(process.env.CSTACK_CODEX_COMPLETION_STALL_MS ?? "", 10); const postOutputStallMs = Number.isFinite(completionStallMs) && completionStallMs > 0 ? completionStallMs : DEFAULT_COMPLETION_STALL_MS; + const staleSessionTimeoutMs = + typeof options.staleSessionTimeoutSeconds === "number" && options.staleSessionTimeoutSeconds > 0 + ? options.staleSessionTimeoutSeconds * 1000 + : undefined; const emitEvent = (event: RunEvent) => { events.write(`${JSON.stringify(event)}\n`); @@ -360,6 +517,16 @@ export async function runCodexExec(options: CodexRunOptions): Promise<CodexRunRe if (event.type !== "heartbeat") { lastActivity = event.message; lastMeaningfulActivityAt = Date.now(); + if (!lastProgressMessage || lastProgressMessage !== event.message) { + lastProgressMessage = event.message; + lastProgressAt = lastMeaningfulActivityAt; + } + if (event.type === "session") { + sessionObservedAt = lastMeaningfulActivityAt; + meaningfulActivitySinceSession = false; + } else if (sessionId) { + meaningfulActivitySinceSession = true; + } } }; @@ -414,6 +581,53 @@ export async function runCodexExec(options: CodexRunOptions): Promise<CodexRunRe child.kill("SIGKILL"); } }, 2_000).unref?.(); + } else if ( + !timedOut && + !stalledAfterSession && + !stalledAfterOutput && + staleSessionTimeoutMs && + sessionId && + sessionObservedAt && + !meaningfulActivitySinceSession && + now - sessionObservedAt >= staleSessionTimeoutMs + ) { + stalledAfterSession = true; + forcedSignal = "SIGTERM"; + stallReason = `Stalled after session setup for ${Math.round(staleSessionTimeoutMs / 1000)}s without meaningful activity`; + lastActivity = stallReason; + emit("failed", stallReason); + stderr.write(`cstack: ${stallReason}\n`); + child.kill("SIGTERM"); + setTimeout(() => { + if (child.exitCode === null) { + forcedSignal = "SIGKILL"; + child.kill("SIGKILL"); + } + }, 2_000).unref?.(); + } else if ( + !timedOut && + !stalledAfterSession && + !stalledAfterNoProgress && + !stalledAfterOutput && + noProgressTimeoutMs && + sessionId && + sessionObservedAt && + lastProgressAt >= sessionObservedAt && + now - lastProgressAt >= noProgressTimeoutMs + ) { + stalledAfterNoProgress = true; + forcedSignal = "SIGTERM"; + stallReason = `No meaningful activity for ${Math.round(noProgressTimeoutMs / 1000)}s after session`; + lastActivity = stallReason; + emit("failed", stallReason); + stderr.write(`cstack: ${stallReason}\n`); + child.kill("SIGTERM"); + setTimeout(() => { + if (child.exitCode === null) { + forcedSignal = "SIGKILL"; + child.kill("SIGKILL"); + } + }, 2_000).unref?.(); } }, 1_000); @@ -435,28 +649,28 @@ export async function runCodexExec(options: CodexRunOptions): Promise<CodexRunRe stdout.write(chunk); const text = chunk.toString("utf8"); lastRawOutputAt = Date.now(); - stdoutBuffer += text; - stdoutPreview = appendPreview(stdoutPreview, text); - flushLines("stdout"); const match = text.match(/session id:\s*([^\s]+)/i); if (match?.[1]) { sessionId = match[1]; emit("session", sessionId); } + stdoutBuffer += text; + stdoutPreview = appendPreview(stdoutPreview, text); + flushLines("stdout"); }); child.stderr.on("data", (chunk: Buffer) => { stderr.write(chunk); const text = chunk.toString("utf8"); lastRawOutputAt = Date.now(); - stderrBuffer += text; - stderrPreview = appendPreview(stderrPreview, text); - flushLines("stderr"); const match = text.match(/session id:\s*([^\s]+)/i); if (match?.[1]) { sessionId = match[1]; emit("session", sessionId); } + stderrBuffer += text; + stderrPreview = appendPreview(stderrPreview, text); + flushLines("stderr"); }); if (timeoutSeconds && timeoutSeconds > 0) { @@ -488,11 +702,21 @@ export async function runCodexExec(options: CodexRunOptions): Promise<CodexRunRe child.on("close", async (code, signal) => { detachStdinError(); clearInterval(heartbeat); - stopTimeouts(); + stopTimeouts(); flushLines("stdout", true); flushLines("stderr", true); - const exitCode = timedOut ? 124 : stalledAfterOutput ? 0 : (code ?? 1); + const exitCode = timedOut || stalledAfterSession || stalledAfterNoProgress ? 124 : stalledAfterOutput ? 0 : (code ?? 1); const resolvedSignal = forcedSignal ?? signal; + const finalArtifact = await ensureFinalArtifact({ + finalPath: options.finalPath, + stdoutPreview, + stderrPreview, + exitCode, + lastActivity + }); + if (finalArtifact.synthesized && finalArtifact.reason) { + emit("activity", finalArtifact.reason); + } if (exitCode === 0) { emit("completed", `Exit code ${exitCode}`); } else if (!timedOut) { @@ -504,8 +728,26 @@ export async function runCodexExec(options: CodexRunOptions): Promise<CodexRunRe command: [invocation.file, ...invocation.args], lastActivity, ...(timedOut ? { timedOut: true } : {}), - ...(timeoutSeconds ? { timeoutSeconds } : {}) + ...(stalledAfterSession || stalledAfterNoProgress + ? { + stalled: true, + ...(stalledAfterNoProgress && noProgressTimeoutMs + ? { timeoutSeconds: Math.round(noProgressTimeoutMs / 1000) } + : timeoutSeconds + ? { timeoutSeconds } + : {}) + } + : timeoutSeconds + ? { timeoutSeconds } + : {}), + ...(stallReason ? { stallReason } : {}) }; + if (finalArtifact.synthesized) { + result.synthesizedFinalArtifact = true; + if (finalArtifact.reason) { + result.synthesizedFinalReason = finalArtifact.reason; + } + } if (sessionId) { result.sessionId = sessionId; } diff --git a/src/commands/build.ts b/src/commands/build.ts index 4bef10b..dac9728 100644 --- a/src/commands/build.ts +++ b/src/commands/build.ts @@ -12,6 +12,8 @@ import type { RunRecord, WorkflowMode } from "../types.js"; export interface BuildCliOptions { fromRunId?: string; requestedMode?: WorkflowMode; + initiativeId?: string; + initiativeTitle?: string; allowDirty?: boolean; } @@ -46,6 +48,24 @@ export function parseBuildArgs(args: string[]): { prompt: string; options: Build options.requestedMode = "exec"; continue; } + if (arg === "--initiative") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack build --initiative` requires an initiative id."); + } + options.initiativeId = value; + index += 1; + continue; + } + if (arg === "--initiative-title") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack build --initiative-title` requires a value."); + } + options.initiativeTitle = value; + index += 1; + continue; + } if (arg === "--allow-dirty") { options.allowDirty = true; continue; @@ -79,6 +99,8 @@ export async function runBuild(cwd: string, args: string[] = []): Promise<string const { config, sources } = await loadConfig(cwd); const allowDirty = parsed.options.allowDirty ?? config.workflows.build.allowDirty ?? false; const linkedContext = parsed.options.fromRunId ? await resolveLinkedBuildContext(cwd, parsed.options.fromRunId) : undefined; + const resolvedInitiativeId = parsed.options.initiativeId ?? linkedContext?.run.inputs.initiativeId; + const resolvedInitiativeTitle = parsed.options.initiativeTitle ?? linkedContext?.run.inputs.initiativeTitle; const runId = makeRunId("build", resolvedPrompt); const runDir = await ensureRunDir(cwd, runId); const promptPath = path.join(runDir, "prompt.md"); @@ -131,6 +153,8 @@ export async function runBuild(cwd: string, args: string[] = []): Promise<string userPrompt: resolvedPrompt, entrypoint: "workflow", ...(linkedContext ? { linkedRunId: linkedContext.run.id } : {}), + ...(resolvedInitiativeId ? { initiativeId: resolvedInitiativeId } : {}), + ...(resolvedInitiativeTitle ? { initiativeTitle: resolvedInitiativeTitle } : {}), requestedMode, verificationCommands, allowDirty, diff --git a/src/commands/deliver.ts b/src/commands/deliver.ts index 2d691d0..f956281 100644 --- a/src/commands/deliver.ts +++ b/src/commands/deliver.ts @@ -11,6 +11,8 @@ export interface DeliverCliOptions { requestedMode?: WorkflowMode; deliveryMode?: DeliverTargetMode; issueNumbers?: number[]; + initiativeId?: string; + initiativeTitle?: string; allowDirty?: boolean; } @@ -46,6 +48,24 @@ export function parseDeliverArgs(args: string[]): { prompt: string; options: Del index += 1; continue; } + if (arg === "--initiative") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack deliver --initiative` requires an initiative id."); + } + options.initiativeId = value; + index += 1; + continue; + } + if (arg === "--initiative-title") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack deliver --initiative-title` requires a value."); + } + options.initiativeTitle = value; + index += 1; + continue; + } if (arg === "--exec") { options.requestedMode = "exec"; continue; @@ -94,6 +114,11 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive const { config, sources } = await loadConfig(cwd); const allowDirty = parsed.options.allowDirty ?? config.workflows.deliver.allowDirty ?? false; const linkedContext = parsed.options.fromRunId ? await resolveLinkedBuildContext(cwd, parsed.options.fromRunId) : undefined; + const resolvedInitiativeId = parsed.options.initiativeId ?? linkedContext?.run.inputs.initiativeId; + const resolvedInitiativeTitle = parsed.options.initiativeTitle ?? linkedContext?.run.inputs.initiativeTitle; + const resolvedIssueNumbers = parsed.options.issueNumbers?.length + ? parsed.options.issueNumbers + : linkedContext?.run.inputs.issueNumbers; const runId = makeRunId("deliver", resolvedPrompt); const runDir = await ensureRunDir(cwd, runId); const promptPath = path.join(runDir, "prompt.md"); @@ -116,7 +141,7 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive ]; const requestedMode = parsed.options.requestedMode ?? config.workflows.deliver.mode ?? config.workflows.build.mode ?? "interactive"; const deliveryMode = parsed.options.deliveryMode ?? config.workflows.deliver.github?.mode ?? "merge-ready"; - const issueNumbers = parsed.options.issueNumbers ?? []; + const issueNumbers = resolvedIssueNumbers ?? []; const buildTimeoutSeconds = config.workflows.deliver.stageTimeoutSeconds?.build ?? config.workflows.build.timeoutSeconds; const reviewTimeoutSeconds = config.workflows.deliver.stageTimeoutSeconds?.review ?? config.workflows.review.timeoutSeconds; const shipTimeoutSeconds = config.workflows.deliver.stageTimeoutSeconds?.ship ?? config.workflows.ship.timeoutSeconds; @@ -149,6 +174,8 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive verificationCommands, deliveryMode, allowDirty, + ...(resolvedInitiativeId ? { initiativeId: resolvedInitiativeId } : {}), + ...(resolvedInitiativeTitle ? { initiativeTitle: resolvedInitiativeTitle } : {}), ...(buildTimeoutSeconds ? { timeoutSeconds: buildTimeoutSeconds } : {}), ...(issueNumbers.length > 0 ? { issueNumbers } : {}) } @@ -192,15 +219,15 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive ...(linkedContext ? { linkedContext } : {}) }); - const buildSession = execution.buildExecution.sessionRecord; - runRecord.status = + const isDeliverCompleted = execution.buildExecution.result.code === 0 && - execution.validationExecution.validationPlan.status !== "blocked" && + execution.validationExecution.validationPlan.status === "ready" && execution.reviewVerdict.status === "ready" && execution.shipRecord.readiness === "ready" && - execution.githubDeliveryRecord.overall.status === "ready" - ? "completed" - : "failed"; + execution.githubDeliveryRecord.overall.status === "ready"; + + const buildSession = execution.buildExecution.sessionRecord; + runRecord.status = isDeliverCompleted ? "completed" : "failed"; runRecord.updatedAt = new Date().toISOString(); delete runRecord.currentStage; runRecord.gitBranch = execution.githubMutationRecord.branch.current || gitBranch; @@ -211,7 +238,7 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive runRecord.lastActivity = execution.buildExecution.result.code !== 0 ? `${execution.buildExecution.failureDiagnosis?.summary ?? (execution.buildExecution.result.timedOut ? `Build timed out after ${execution.buildExecution.result.timeoutSeconds}s` : `Build failed with exit code ${execution.buildExecution.result.code}`)}; downstream stages blocked` - : `Validation: ${execution.validationExecution.validationPlan.status}; Ship readiness: ${execution.shipRecord.readiness}; GitHub delivery: ${execution.githubDeliveryRecord.overall.status}`; + : `Validation: ${execution.validationExecution.validationPlan.status} (${execution.validationExecution.validationPlan.outcomeCategory}); Ship readiness: ${execution.shipRecord.readiness}; GitHub delivery: ${execution.githubDeliveryRecord.overall.status}`; runRecord.inputs.observedMode = execution.buildExecution.observedMode; runRecord.inputs.selectedSpecialists = execution.selectedSpecialists.map((specialist) => specialist.name); runRecord.inputs.deliveryMode = deliveryMode; @@ -230,7 +257,10 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive ].join("; ") : [ execution.validationExecution.validationPlan.status === "blocked" - ? `validation status: ${execution.validationExecution.validationPlan.status}` + ? `validation blocked via ${execution.validationExecution.validationPlan.outcomeCategory}` + : null, + execution.validationExecution.validationPlan.status === "partial" + ? `validation partial: ${execution.validationExecution.validationPlan.summary}` : null, execution.reviewVerdict.status !== "ready" ? `review status: ${execution.reviewVerdict.status}` : null, execution.shipRecord.readiness === "blocked" ? "ship stage blocked release readiness" : null, @@ -255,7 +285,7 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive executionCheckout.record.source.dirtyFiles.length > 0 && !allowDirty ? "Local dirty changes were ignored; execution used committed HEAD." : undefined, - `Validation: ${execution.validationExecution.validationPlan.status}`, + `Validation: ${execution.validationExecution.validationPlan.status} (${execution.validationExecution.validationPlan.outcomeCategory})`, `Review verdict: ${execution.reviewVerdict.status}`, `Ship readiness: ${execution.shipRecord.readiness}`, `GitHub mutation: ${execution.githubMutationRecord.summary}`, @@ -266,12 +296,20 @@ export async function runDeliver(cwd: string, args: string[] = [], hooks: Delive ` ${path.relative(cwd, deliveryReportPath)}`, ` ${path.relative(cwd, path.join(runDir, "artifacts", "github-mutation.json"))}`, ` ${path.relative(cwd, path.join(runDir, "artifacts", "github-delivery.json"))}`, + ` ${path.relative(cwd, path.join(runDir, "artifacts", "readiness-policy.json"))}`, + ` ${path.relative(cwd, path.join(runDir, "artifacts", "deployment-evidence.json"))}`, ` ${path.relative(cwd, stageLineagePath)}`, ` ${path.relative(cwd, path.join(runDir, "stages", "build", "artifacts", "change-summary.md"))}`, ` ${path.relative(cwd, path.join(runDir, "stages", "validation", "validation-plan.json"))}`, ` ${path.relative(cwd, path.join(runDir, "stages", "validation", "artifacts", "test-pyramid.md"))}`, ` ${path.relative(cwd, path.join(runDir, "stages", "review", "artifacts", "verdict.json"))}`, ` ${path.relative(cwd, path.join(runDir, "stages", "ship", "artifacts", "ship-summary.md"))}`, + ` ${path.relative(cwd, path.join(runDir, "stages", "ship", "artifacts", "readiness-policy.json"))}`, + ` ${path.relative(cwd, path.join(runDir, "stages", "ship", "artifacts", "deployment-evidence.json"))}`, + ` ${path.relative(cwd, path.join(runDir, "artifacts", "post-ship-summary.md"))}`, + ` ${path.relative(cwd, path.join(runDir, "artifacts", "post-ship-evidence.json"))}`, + ` ${path.relative(cwd, path.join(runDir, "artifacts", "follow-up-draft.md"))}`, + ` ${path.relative(cwd, path.join(runDir, "artifacts", "follow-up-lineage.json"))}`, ` ${path.relative(cwd, path.join(runDir, "run.json"))}` ].join("\n") + "\n" ); diff --git a/src/commands/discover.ts b/src/commands/discover.ts index 9f20cf7..84dd9f6 100644 --- a/src/commands/discover.ts +++ b/src/commands/discover.ts @@ -1,9 +1,42 @@ import path from "node:path"; +import { promises as fs } from "node:fs"; import { loadConfig } from "../config.js"; import { runDiscoverExecution } from "../discover.js"; import { maybeOfferInteractiveInspect } from "../inspector.js"; import { detectCodexVersion, detectGitBranch, ensureRunDir, makeRunId, writeRunRecord } from "../run.js"; -import type { RunRecord } from "../types.js"; +import type { PlanningIssueLineageRecord, RunRecord } from "../types.js"; + +export interface DiscoverCliOptions { + planningIssueNumber?: number; +} + +function parseDiscoverArgs(input: string | string[]): { prompt: string; options: DiscoverCliOptions } { + const parts = Array.isArray(input) ? input : input.trim() ? input.split(/\s+/) : []; + const options: DiscoverCliOptions = {}; + const promptParts: string[] = []; + + for (let index = 0; index < parts.length; index += 1) { + const arg = parts[index]!; + if (arg === "--issue") { + const value = parts[index + 1]; + if (!value || !/^\d+$/.test(value)) { + throw new Error("`cstack discover --issue` requires a numeric issue id."); + } + options.planningIssueNumber = Number.parseInt(value, 10); + index += 1; + continue; + } + if (arg.startsWith("-")) { + throw new Error(`Unknown discover option: ${arg}`); + } + promptParts.push(arg); + } + + return { + prompt: promptParts.join(" ").trim(), + options + }; +} async function readPromptFromStdin(): Promise<string> { if (process.stdin.isTTY) { @@ -17,8 +50,9 @@ async function readPromptFromStdin(): Promise<string> { return chunks.join("").trim(); } -export async function runDiscover(cwd: string, userPrompt: string): Promise<string> { - const resolvedPrompt = userPrompt.trim() || (await readPromptFromStdin()); +export async function runDiscover(cwd: string, input: string | string[]): Promise<string> { + const parsed = parseDiscoverArgs(input); + const resolvedPrompt = parsed.prompt || (await readPromptFromStdin()); if (!resolvedPrompt) { throw new Error("`cstack discover` requires a prompt."); } @@ -33,6 +67,7 @@ export async function runDiscover(cwd: string, userPrompt: string): Promise<stri const eventsPath = path.join(runDir, "events.jsonl"); const stdoutPath = path.join(runDir, "stdout.log"); const stderrPath = path.join(runDir, "stderr.log"); + const issueLineagePath = path.join(runDir, "artifacts", "issue-lineage.json"); const stageDir = path.join(runDir, "stages", "discover"); const [gitBranch, codexVersion] = await Promise.all([ detectGitBranch(cwd), @@ -60,7 +95,8 @@ export async function runDiscover(cwd: string, userPrompt: string): Promise<stri currentStage: "discover", summary: resolvedPrompt, inputs: { - userPrompt: resolvedPrompt + userPrompt: resolvedPrompt, + ...(parsed.options.planningIssueNumber ? { planningIssueNumber: parsed.options.planningIssueNumber } : {}) } }; @@ -72,6 +108,9 @@ export async function runDiscover(cwd: string, userPrompt: string): Promise<stri runId, input: resolvedPrompt, config, + ...(typeof parsed.options.planningIssueNumber === "number" + ? { planningIssueNumber: parsed.options.planningIssueNumber } + : {}), paths: { runDir, stageDir, @@ -85,7 +124,7 @@ export async function runDiscover(cwd: string, userPrompt: string): Promise<stri } }); - runRecord.status = result.leadResult.code === 0 ? "completed" : "failed"; + runRecord.status = result.status === "failed" ? "failed" : "completed"; runRecord.updatedAt = new Date().toISOString(); delete runRecord.currentStage; runRecord.codexCommand = result.leadResult.command; @@ -95,11 +134,26 @@ export async function runDiscover(cwd: string, userPrompt: string): Promise<stri if (result.leadResult.lastActivity) { runRecord.lastActivity = result.leadResult.lastActivity; } + if (result.status === "partial") { + runRecord.lastActivity = `Discover recovered a partial artifact after a non-zero lead exit.${result.notes[0] ? ` ${result.notes[0]}` : ""}`; + } runRecord.inputs.delegatedTracks = result.researchPlan.tracks.filter((track) => track.selected).map((track) => track.name); runRecord.inputs.webResearchAllowed = result.researchPlan.webResearchAllowed; + if (parsed.options.planningIssueNumber) { + const issueLineage: PlanningIssueLineageRecord = { + planningIssueNumber: parsed.options.planningIssueNumber, + currentRun: { + runId, + workflow: "discover" + }, + downstreamPullRequests: [], + downstreamReleases: [] + }; + await fs.writeFile(issueLineagePath, `${JSON.stringify(issueLineage, null, 2)}\n`, "utf8"); + } await writeRunRecord(runDir, runRecord); - if (result.leadResult.code !== 0) { + if (result.status === "failed") { throw new Error(`discover research lead exited with code ${result.leadResult.code}`); } @@ -113,6 +167,7 @@ export async function runDiscover(cwd: string, userPrompt: string): Promise<stri ` ${path.relative(cwd, artifactPath)}`, ` ${path.relative(cwd, path.join(stageDir, "artifacts", "discovery-report.md"))}`, ` ${path.relative(cwd, path.join(stageDir, "research-plan.json"))}`, + ...(parsed.options.planningIssueNumber ? [` ${path.relative(cwd, issueLineagePath)}`] : []), ` ${path.relative(cwd, eventsPath)}`, ` ${path.relative(cwd, path.join(runDir, "run.json"))}` ].join("\n") + "\n" diff --git a/src/commands/rerun.ts b/src/commands/rerun.ts index 3eb96c1..9b94daf 100644 --- a/src/commands/rerun.ts +++ b/src/commands/rerun.ts @@ -28,12 +28,18 @@ function buildWorkflowArgs(run: RunRecord): string[] { switch (run.workflow) { case "discover": + if (typeof run.inputs.planningIssueNumber === "number") { + args.push("--issue", String(run.inputs.planningIssueNumber)); + } args.push(prompt); return args; case "spec": if (linkedRunId) { args.push("--from-run", linkedRunId); } + if (typeof run.inputs.planningIssueNumber === "number") { + args.push("--issue", String(run.inputs.planningIssueNumber)); + } if (prompt) { args.push(prompt); } diff --git a/src/commands/review.ts b/src/commands/review.ts index be028be..e4217b0 100644 --- a/src/commands/review.ts +++ b/src/commands/review.ts @@ -7,6 +7,8 @@ import type { RunRecord } from "../types.js"; export interface ReviewCliOptions { fromRunId?: string; + initiativeId?: string; + initiativeTitle?: string; } export interface ReviewRunHooks { @@ -41,6 +43,24 @@ export function parseReviewArgs(args: string[]): { prompt: string; options: Revi index += 1; continue; } + if (arg === "--initiative") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack review --initiative` requires an initiative id."); + } + options.initiativeId = value; + index += 1; + continue; + } + if (arg === "--initiative-title") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack review --initiative-title` requires a value."); + } + options.initiativeTitle = value; + index += 1; + continue; + } if (arg.startsWith("-")) { throw new Error(`Unknown review option: ${arg}`); } @@ -67,6 +87,8 @@ export async function runReview(cwd: string, args: string[] = [], hooks: ReviewR const { config, sources } = await loadConfig(cwd); const linkedContext = parsed.options.fromRunId ? await resolveLinkedReviewContext(cwd, parsed.options.fromRunId) : undefined; + const resolvedInitiativeId = parsed.options.initiativeId ?? linkedContext?.initiativeId; + const resolvedInitiativeTitle = parsed.options.initiativeTitle ?? linkedContext?.initiativeTitle; const runId = makeRunId("review", resolvedPrompt); const runDir = await ensureRunDir(cwd, runId); const promptPath = path.join(runDir, "prompt.md"); @@ -107,6 +129,8 @@ export async function runReview(cwd: string, args: string[] = [], hooks: ReviewR inputs: { userPrompt: resolvedPrompt, entrypoint: "workflow", + ...(resolvedInitiativeId ? { initiativeId: resolvedInitiativeId } : {}), + ...(resolvedInitiativeTitle ? { initiativeTitle: resolvedInitiativeTitle } : {}), ...(linkedContext ? { linkedRunId: linkedContext.runId } : {}) } }; diff --git a/src/commands/runs.ts b/src/commands/runs.ts index 65b7cef..80e2414 100644 --- a/src/commands/runs.ts +++ b/src/commands/runs.ts @@ -5,6 +5,8 @@ export interface RunsCommandOptions { activeOnly: boolean; workflow?: WorkflowName; status?: RunStatus; + planningIssueNumber?: number; + initiativeId?: string; recent?: number; json: boolean; } @@ -51,6 +53,24 @@ export function parseRunsArgs(args: string[]): RunsCommandOptions { index += 1; continue; } + if (arg === "--issue") { + const value = args[index + 1]; + if (!value || !/^\d+$/.test(value)) { + throw new Error("`cstack runs --issue` requires a numeric issue id."); + } + options.planningIssueNumber = Number.parseInt(value, 10); + index += 1; + continue; + } + if (arg === "--initiative") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack runs --initiative` requires an initiative id."); + } + options.initiativeId = value; + index += 1; + continue; + } if (arg === "--recent") { const value = args[index + 1]; if (!value) { diff --git a/src/commands/ship.ts b/src/commands/ship.ts index eb66396..ced3cef 100644 --- a/src/commands/ship.ts +++ b/src/commands/ship.ts @@ -10,6 +10,8 @@ export interface ShipCliOptions { fromRunId?: string; deliveryMode?: DeliverTargetMode; issueNumbers?: number[]; + initiativeId?: string; + initiativeTitle?: string; allowDirty?: boolean; } @@ -45,6 +47,24 @@ export function parseShipArgs(args: string[]): { prompt: string; options: ShipCl index += 1; continue; } + if (arg === "--initiative") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack ship --initiative` requires an initiative id."); + } + options.initiativeId = value; + index += 1; + continue; + } + if (arg === "--initiative-title") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack ship --initiative-title` requires a value."); + } + options.initiativeTitle = value; + index += 1; + continue; + } if (arg === "--release") { options.deliveryMode = "release"; continue; @@ -91,6 +111,8 @@ export async function runShip(cwd: string, args: string[] = [], hooks: ShipRunHo await ensureCleanWorktreeForWorkflow(cwd, "ship", allowDirty); const linkedContext = parsed.options.fromRunId ? await resolveLinkedShipContext(cwd, parsed.options.fromRunId) : undefined; + const resolvedInitiativeId = parsed.options.initiativeId ?? linkedContext?.initiativeId; + const resolvedInitiativeTitle = parsed.options.initiativeTitle ?? linkedContext?.initiativeTitle; const runId = makeRunId("ship", resolvedPrompt); const runDir = await ensureRunDir(cwd, runId); const promptPath = path.join(runDir, "prompt.md"); @@ -103,6 +125,12 @@ export async function runShip(cwd: string, args: string[] = [], hooks: ShipRunHo const checklistPath = path.join(runDir, "artifacts", "release-checklist.md"); const unresolvedPath = path.join(runDir, "artifacts", "unresolved.md"); const shipRecordPath = path.join(runDir, "artifacts", "ship-record.json"); + const readinessPolicyPath = path.join(runDir, "artifacts", "readiness-policy.json"); + const deploymentEvidencePath = path.join(runDir, "artifacts", "deployment-evidence.json"); + const postShipSummaryPath = path.join(runDir, "artifacts", "post-ship-summary.md"); + const postShipEvidencePath = path.join(runDir, "artifacts", "post-ship-evidence.json"); + const followUpDraftPath = path.join(runDir, "artifacts", "follow-up-draft.md"); + const followUpLineagePath = path.join(runDir, "artifacts", "follow-up-lineage.json"); const githubMutationPath = path.join(runDir, "artifacts", "github-mutation.json"); const githubDeliveryPath = path.join(runDir, "artifacts", "github-delivery.json"); const stageLineagePath = path.join(runDir, "stage-lineage.json"); @@ -138,6 +166,8 @@ export async function runShip(cwd: string, args: string[] = [], hooks: ShipRunHo userPrompt: resolvedPrompt, entrypoint: "workflow", ...(linkedContext ? { linkedRunId: linkedContext.runId } : {}), + ...(resolvedInitiativeId ? { initiativeId: resolvedInitiativeId } : {}), + ...(resolvedInitiativeTitle ? { initiativeTitle: resolvedInitiativeTitle } : {}), deliveryMode, issueNumbers, allowDirty @@ -165,6 +195,12 @@ export async function runShip(cwd: string, args: string[] = [], hooks: ShipRunHo checklistPath, unresolvedPath, shipRecordPath, + readinessPolicyPath, + deploymentEvidencePath, + postShipSummaryPath, + postShipEvidencePath, + followUpDraftPath, + followUpLineagePath, githubMutationPath, githubDeliveryPath, stageLineagePath, @@ -203,6 +239,12 @@ export async function runShip(cwd: string, args: string[] = [], hooks: ShipRunHo ` ${path.relative(cwd, shipSummaryPath)}`, ` ${path.relative(cwd, checklistPath)}`, ` ${path.relative(cwd, unresolvedPath)}`, + ` ${path.relative(cwd, postShipSummaryPath)}`, + ` ${path.relative(cwd, postShipEvidencePath)}`, + ` ${path.relative(cwd, followUpDraftPath)}`, + ` ${path.relative(cwd, followUpLineagePath)}`, + ` ${path.relative(cwd, readinessPolicyPath)}`, + ` ${path.relative(cwd, deploymentEvidencePath)}`, ` ${path.relative(cwd, githubMutationPath)}`, ` ${path.relative(cwd, githubDeliveryPath)}`, ` ${path.relative(cwd, stageLineagePath)}`, diff --git a/src/commands/spec.ts b/src/commands/spec.ts index 5e88d37..2d97efa 100644 --- a/src/commands/spec.ts +++ b/src/commands/spec.ts @@ -4,12 +4,15 @@ import { loadConfig } from "../config.js"; import { runCodexExec } from "../codex.js"; import { maybeOfferInteractiveInspect } from "../inspector.js"; import { buildSpecPrompt, excerpt } from "../prompt.js"; -import { detectCodexVersion, detectGitBranch, ensureRunDir, makeRunId, writeRunRecord } from "../run.js"; +import { detectCodexVersion, detectGitBranch, ensureRunDir, listRuns, makeRunId, writeRunRecord } from "../run.js"; import { resolveLinkedBuildContext } from "../build.js"; -import type { RunRecord } from "../types.js"; +import type { InitiativeGraphRecord, PlanningIssueLineageRecord, RunRecord } from "../types.js"; export interface SpecCliOptions { fromRunId?: string; + planningIssueNumber?: number; + initiativeId?: string; + initiativeTitle?: string; } function parseSpecArgs(args: string[]): { prompt: string; options: SpecCliOptions } { @@ -27,6 +30,33 @@ function parseSpecArgs(args: string[]): { prompt: string; options: SpecCliOption index += 1; continue; } + if (arg === "--issue") { + const value = args[index + 1]; + if (!value || !/^\d+$/.test(value)) { + throw new Error("`cstack spec --issue` requires a numeric issue id."); + } + options.planningIssueNumber = Number.parseInt(value, 10); + index += 1; + continue; + } + if (arg === "--initiative") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack spec --initiative` requires an initiative id."); + } + options.initiativeId = value; + index += 1; + continue; + } + if (arg === "--initiative-title") { + const value = args[index + 1]; + if (!value) { + throw new Error("`cstack spec --initiative-title` requires a value."); + } + options.initiativeTitle = value; + index += 1; + continue; + } if (arg.startsWith("-")) { throw new Error(`Unknown spec option: ${arg}`); } @@ -62,6 +92,38 @@ function deriveOpenQuestions(finalBody: string): string { return ["# Open Questions", "", ...(questions.length > 0 ? questions.map((line) => `- ${line}`) : ["- none"])].join("\n") + "\n"; } +function deriveIssueDraft(options: { + finalBody: string; + prompt: string; + planningIssueNumber: number; + linkedContext?: { run: { id: string; workflow: string } }; +}): string { + const plan = derivePlanArtifact(options.finalBody); + const steps = Array.isArray(plan.steps) ? plan.steps.map((step) => String(step)) : []; + const openQuestions = Array.isArray(plan.openQuestions) ? plan.openQuestions.map((item) => String(item)) : []; + + return [ + `# Planning Issue Draft: #${options.planningIssueNumber}`, + "", + `Related issue: #${options.planningIssueNumber}`, + "", + "## Summary", + typeof plan.summary === "string" && plan.summary.trim() ? plan.summary : options.prompt, + "", + "## Requested outcome", + options.prompt, + "", + "## Proposed implementation slice", + ...(steps.length > 0 ? steps.map((step) => `- ${step}`) : ["- refine the implementation slice from the saved spec output"]), + "", + "## Open questions", + ...(openQuestions.length > 0 ? openQuestions.map((question) => `- ${question}`) : ["- none recorded"]), + "", + "## Lineage", + options.linkedContext ? `- upstream run: ${options.linkedContext.run.id} (${options.linkedContext.run.workflow})` : "- upstream run: none" + ].join("\n") + "\n"; +} + async function readPromptFromStdin(): Promise<string> { if (process.stdin.isTTY) { return ""; @@ -84,6 +146,10 @@ export async function runSpec(cwd: string, input: string | string[]): Promise<st const { config, sources } = await loadConfig(cwd); const linkedContext = parsed.options.fromRunId ? await resolveLinkedBuildContext(cwd, parsed.options.fromRunId) : undefined; + const resolvedPlanningIssueNumber = + typeof parsed.options.planningIssueNumber === "number" + ? parsed.options.planningIssueNumber + : linkedContext?.run.inputs.planningIssueNumber; const specInput = linkedContext && resolvedPrompt ? `${resolvedPrompt}\n\n## Linked upstream run\n- run: ${linkedContext.run.id}\n- workflow: ${linkedContext.run.workflow}\n\n## Linked artifact excerpt\n${excerpt(linkedContext.artifactBody, 40)}` @@ -98,14 +164,31 @@ export async function runSpec(cwd: string, input: string | string[]): Promise<st const artifactPath = path.join(runDir, "artifacts", "spec.md"); const planPath = path.join(runDir, "artifacts", "plan.json"); const openQuestionsPath = path.join(runDir, "artifacts", "open-questions.md"); + const issueDraftPath = path.join(runDir, "artifacts", "issue-draft.md"); + const issueLineagePath = path.join(runDir, "artifacts", "issue-lineage.json"); + const initiativeGraphPath = path.join(runDir, "artifacts", "initiative-graph.json"); const eventsPath = path.join(runDir, "events.jsonl"); const stdoutPath = path.join(runDir, "stdout.log"); const stderrPath = path.join(runDir, "stderr.log"); + const resolvedInitiativeId = parsed.options.initiativeId + ? parsed.options.initiativeId + : linkedContext?.run.inputs.initiativeId; + const resolvedInitiativeTitle = parsed.options.initiativeTitle + ? parsed.options.initiativeTitle + : linkedContext?.run.inputs.initiativeTitle; + const priorInitiativeRuns = resolvedInitiativeId ? await listRuns(cwd) : []; const [gitBranch, codexVersion] = await Promise.all([ detectGitBranch(cwd), detectCodexVersion(cwd, config.codex.command) ]); - const { prompt, context } = await buildSpecPrompt(cwd, specInput, config); + const promptOptions = { + ...(typeof resolvedPlanningIssueNumber === "number" + ? { planningIssueNumber: resolvedPlanningIssueNumber } + : {}), + ...(typeof resolvedInitiativeId === "string" ? { initiativeId: resolvedInitiativeId } : {}), + ...(typeof resolvedInitiativeTitle === "string" ? { initiativeTitle: resolvedInitiativeTitle } : {}) + }; + const { prompt, context } = await buildSpecPrompt(cwd, specInput, config, promptOptions); await fs.writeFile(promptPath, prompt, "utf8"); await fs.writeFile(contextPath, `${context}\n`, "utf8"); @@ -131,9 +214,11 @@ export async function runSpec(cwd: string, input: string | string[]): Promise<st currentStage: "spec", summary: resolvedPrompt, inputs: { - userPrompt: resolvedPrompt - , - ...(linkedContext ? { linkedRunId: linkedContext.run.id } : {}) + userPrompt: resolvedPrompt, + ...(linkedContext ? { linkedRunId: linkedContext.run.id } : {}), + ...(typeof resolvedPlanningIssueNumber === "number" ? { planningIssueNumber: resolvedPlanningIssueNumber } : {}), + ...(resolvedInitiativeId ? { initiativeId: resolvedInitiativeId } : {}), + ...(resolvedInitiativeTitle ? { initiativeTitle: resolvedInitiativeTitle } : {}) } }; @@ -170,6 +255,77 @@ export async function runSpec(cwd: string, input: string | string[]): Promise<st await fs.writeFile(artifactPath, finalBody, "utf8"); await fs.writeFile(planPath, `${JSON.stringify(derivePlanArtifact(finalBody), null, 2)}\n`, "utf8"); await fs.writeFile(openQuestionsPath, deriveOpenQuestions(finalBody), "utf8"); + if (resolvedInitiativeId) { + const relatedRuns = priorInitiativeRuns + .filter((run) => run.inputs?.initiativeId === resolvedInitiativeId) + .map((run) => ({ + runId: run.id, + workflow: run.workflow, + status: run.status + })); + const initiativeGraph: InitiativeGraphRecord = { + initiativeId: resolvedInitiativeId, + ...(resolvedInitiativeTitle ? { initiativeTitle: resolvedInitiativeTitle } : {}), + ...(parsed.options.fromRunId + ? { + sourceRun: { + runId: linkedContext!.run.id, + workflow: linkedContext!.run.workflow + } + } + : relatedRuns.length > 0 + ? { + sourceRun: { + runId: relatedRuns[0]!.runId, + workflow: relatedRuns[0]!.workflow + } + } + : {}), + currentRun: { + runId, + workflow: "spec" + }, + relatedRuns: [ + ...relatedRuns, + { + runId, + workflow: "spec", + status: runRecord.status + } + ] + }; + await fs.writeFile(initiativeGraphPath, `${JSON.stringify(initiativeGraph, null, 2)}\n`, "utf8"); + } + if (typeof resolvedPlanningIssueNumber === "number") { + const issueLineage: PlanningIssueLineageRecord = { + planningIssueNumber: resolvedPlanningIssueNumber, + currentRun: { + runId, + workflow: "spec" + }, + ...(linkedContext + ? { + sourceRun: { + runId: linkedContext.run.id, + workflow: linkedContext.run.workflow + } + } + : {}), + downstreamPullRequests: [], + downstreamReleases: [] + }; + await fs.writeFile( + issueDraftPath, + deriveIssueDraft({ + finalBody, + prompt: resolvedPrompt || specInput, + planningIssueNumber: resolvedPlanningIssueNumber, + ...(linkedContext ? { linkedContext } : {}) + }), + "utf8" + ); + await fs.writeFile(issueLineagePath, `${JSON.stringify(issueLineage, null, 2)}\n`, "utf8"); + } } await writeRunRecord(runDir, runRecord); @@ -187,6 +343,10 @@ export async function runSpec(cwd: string, input: string | string[]): Promise<st ` ${path.relative(cwd, artifactPath)}`, ` ${path.relative(cwd, planPath)}`, ` ${path.relative(cwd, openQuestionsPath)}`, + ...(typeof resolvedPlanningIssueNumber === "number" + ? [` ${path.relative(cwd, issueDraftPath)}`, ` ${path.relative(cwd, issueLineagePath)}`] + : []), + ...(resolvedInitiativeId ? [` ${path.relative(cwd, initiativeGraphPath)}`] : []), ` ${path.relative(cwd, eventsPath)}`, ` ${path.relative(cwd, path.join(runDir, "run.json"))}` ].join("\n") + "\n" diff --git a/src/config.ts b/src/config.ts index 5fe43fe..05d3494 100644 --- a/src/config.ts +++ b/src/config.ts @@ -13,12 +13,20 @@ const DEFAULT_CONFIG: CstackConfig = { workflows: { spec: { timeoutSeconds: 600, + capabilities: { + allowed: ["shell", "github"], + defaultRequested: ["shell"] + }, delegation: { enabled: false, maxAgents: 0 } }, discover: { + capabilities: { + allowed: ["shell", "web", "github", "browser"], + defaultRequested: ["shell"] + }, delegation: { enabled: true, maxAgents: 2 @@ -34,6 +42,10 @@ const DEFAULT_CONFIG: CstackConfig = { verificationCommands: [], allowDirty: false, timeoutSeconds: 900, + capabilities: { + allowed: ["shell", "github"], + defaultRequested: ["shell"] + }, delegation: { enabled: false, maxAgents: 0 @@ -44,6 +56,10 @@ const DEFAULT_CONFIG: CstackConfig = { verificationCommands: [], allowDirty: true, timeoutSeconds: 600, + capabilities: { + allowed: ["shell", "github"], + defaultRequested: ["shell"] + }, delegation: { enabled: true, maxAgents: 3 @@ -54,6 +70,10 @@ const DEFAULT_CONFIG: CstackConfig = { verificationCommands: [], allowDirty: false, timeoutSeconds: 600, + capabilities: { + allowed: ["shell", "github"], + defaultRequested: ["shell", "github"] + }, delegation: { enabled: false, maxAgents: 0 @@ -64,6 +84,10 @@ const DEFAULT_CONFIG: CstackConfig = { verificationCommands: [], allowDirty: false, timeoutSeconds: 900, + capabilities: { + allowed: ["shell", "github", "browser"], + defaultRequested: ["shell", "github"] + }, stageTimeoutSeconds: { build: 900, validation: 300, @@ -245,6 +269,16 @@ function validateResearchConfig(source: string, configPath: string, value: unkno } } +function validateCapabilitiesConfig(source: string, configPath: string, value: unknown): void { + const objectValue = expectObject(source, configPath, value); + if ("allowed" in objectValue) { + validateStringArray(source, `${configPath}.allowed`, objectValue.allowed); + } + if ("defaultRequested" in objectValue) { + validateStringArray(source, `${configPath}.defaultRequested`, objectValue.defaultRequested); + } +} + function validateDeliverValidationConfig(source: string, configPath: string, value: unknown): void { const objectValue = expectObject(source, configPath, value); if ("enabled" in objectValue) { @@ -417,6 +451,9 @@ function validateWorkflowConfig(source: string, workflowName: string, value: unk if ("research" in objectValue) { validateResearchConfig(source, `${configPath}.research`, objectValue.research); } + if ("capabilities" in objectValue) { + validateCapabilitiesConfig(source, `${configPath}.capabilities`, objectValue.capabilities); + } if ("validation" in objectValue) { validateDeliverValidationConfig(source, `${configPath}.validation`, objectValue.validation); } diff --git a/src/deliver.ts b/src/deliver.ts index 7a11b7d..19183ac 100644 --- a/src/deliver.ts +++ b/src/deliver.ts @@ -2,8 +2,10 @@ import path from "node:path"; import { promises as fs } from "node:fs"; import { buildEvent, ProgressReporter } from "./progress.js"; import { resolveLinkedBuildContext, runBuildExecution, type BuildExecutionResult, type LinkedBuildContext } from "./build.js"; -import { runCodexExec } from "./codex.js"; +import { readCodexFinalOutput, runCodexExec } from "./codex.js"; import { collectGitHubDeliveryEvidence, performGitHubDeliverMutations } from "./github.js"; +import { buildPostShipArtifacts } from "./post-ship.js"; +import { buildDeploymentEvidenceRecord, buildReadinessPolicyRecord } from "./ship.js"; import { buildDeliverPrompt, buildDeliverReviewLeadPrompt, @@ -14,6 +16,8 @@ import { inferRoutingPlan } from "./intent.js"; import { runDeliverValidationExecution, type DeliverValidationExecutionResult } from "./validation.js"; import type { CstackConfig, + DeliveryReadinessPolicyRecord, + DeploymentEvidenceRecord, DeliverTargetMode, DeliverReviewVerdict, DeliverShipRecord, @@ -26,6 +30,7 @@ import type { StageLineage, WorkflowMode } from "./types.js"; +import type { PerformGitHubMutationResult } from "./github.js"; export interface DeliverPaths { runDir: string; @@ -188,7 +193,13 @@ async function runDeliverSpecialist(options: { config: options.config }); - const finalBody = await fs.readFile(finalPath, "utf8"); + const finalBody = await readCodexFinalOutput({ + context: `Deliver specialist ${options.specialist.name}`, + finalPath, + stdoutPath, + stderrPath, + result + }); await fs.writeFile(artifactPath, finalBody, "utf8"); return { @@ -272,6 +283,7 @@ function createBlockedValidationExecution(buildExecution: BuildExecutionResult): }, validationPlan: { status: "blocked", + outcomeCategory: "blocked-by-build", summary, profileSummary: "Validation profiling was skipped because build failed first.", layers: [], @@ -306,6 +318,7 @@ function createBlockedValidationExecution(buildExecution: BuildExecutionResult): }, coverageSummary: { status: "blocked", + outcomeCategory: "blocked-by-build", confidence: "low", summary: "No validation evidence was collected because build failed first.", signals: [], @@ -460,6 +473,8 @@ async function writeBlockedDeliverStageArtifacts(options: { validationExecution: DeliverValidationExecutionResult; reviewVerdict: DeliverReviewVerdict; shipRecord: DeliverShipRecord; + readinessPolicyRecord: DeliveryReadinessPolicyRecord; + deploymentEvidenceRecord: DeploymentEvidenceRecord; githubMutationRecord: GitHubMutationRecord; githubDeliveryRecord: GitHubDeliveryRecord; }): Promise<void> { @@ -504,6 +519,8 @@ async function writeBlockedDeliverStageArtifacts(options: { await fs.writeFile(path.join(options.shipStageDir, "artifacts", "release-checklist.md"), renderChecklistMarkdown(options.shipRecord), "utf8"); await fs.writeFile(path.join(options.shipStageDir, "artifacts", "unresolved.md"), renderUnresolvedMarkdown(options.shipRecord), "utf8"); await writeJson(path.join(options.shipStageDir, "artifacts", "ship-record.json"), options.shipRecord); + await writeJson(path.join(options.shipStageDir, "artifacts", "readiness-policy.json"), options.readinessPolicyRecord); + await writeJson(path.join(options.shipStageDir, "artifacts", "deployment-evidence.json"), options.deploymentEvidenceRecord); await writeJson(path.join(options.shipStageDir, "artifacts", "github-state.json"), { status: "blocked", summary: options.githubDeliveryRecord.overall.summary }); await writeJson(path.join(options.shipStageDir, "artifacts", "pull-request.json"), options.githubDeliveryRecord.pullRequest); await writeJson(path.join(options.shipStageDir, "artifacts", "issues.json"), options.githubDeliveryRecord.issues); @@ -523,7 +540,13 @@ function buildDeliverFinalSummary(options: { shipRecord: DeliverShipRecord; githubDeliveryRecord: GitHubDeliveryRecord; githubMutationRecord: GitHubMutationRecord; + readinessPolicyRecord: DeliveryReadinessPolicyRecord; }): string { + const postReadinessSummary = options.readinessPolicyRecord?.postReadinessSummary; + const postReadinessHeadline = postReadinessSummary?.headline ?? "Post-readiness summary unavailable"; + const postReadinessHighlights = postReadinessSummary?.highlights ?? []; + const postReadinessBlockers = postReadinessSummary?.blockers ?? []; + return [ "# Deliver Run Summary", "", @@ -538,6 +561,7 @@ function buildDeliverFinalSummary(options: { "", "## Validation", `- status: ${options.validationExecution.validationPlan.status}`, + `- outcome category: ${options.validationExecution.validationPlan.outcomeCategory}`, `- summary: ${options.validationExecution.validationPlan.summary}`, `- local validation: ${options.validationExecution.localValidationRecord.status}`, ...options.validationExecution.coverageSummary.gaps.map((gap) => `- gap: ${gap}`), @@ -552,6 +576,13 @@ function buildDeliverFinalSummary(options: { `- summary: ${options.shipRecord.summary}`, ...options.shipRecord.nextActions.map((action) => `- next: ${action}`), "", + "## Post-readiness summary", + `- headline: ${postReadinessHeadline}`, + ...postReadinessHighlights.map((highlight) => `- highlight: ${highlight}`), + ...(postReadinessBlockers.length > 0 + ? postReadinessBlockers.map((blocker) => `- blocker class: ${blocker}`) + : ["- blocker class: none"]), + "", "## GitHub mutations", `- summary: ${options.githubMutationRecord.summary}`, `- branch: ${options.githubMutationRecord.branch.current}`, @@ -573,6 +604,320 @@ function parseJson<T>(raw: string, context: string): T { } } +function summarizeOrThrow(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function createFailedValidationExecution(error: string): DeliverValidationExecutionResult { + const summary = `Validation stage failed: ${error}`; + return { + repoProfile: { + detectedAt: new Date().toISOString(), + languages: ["typescript"], + buildSystems: [], + surfaces: ["cli"], + packageManagers: [], + ciSystems: [], + runnerConstraints: [], + manifests: [], + workflowFiles: [], + existingTests: [], + packageScripts: [], + detectedTools: [], + workspaceTargets: [], + limitations: [summary] + }, + toolResearch: { + generatedAt: new Date().toISOString(), + summary, + candidates: [], + selectedTools: [], + limitations: [summary] + }, + validationPlan: { + status: "blocked", + outcomeCategory: "blocked-by-validation", + summary, + profileSummary: summary, + layers: [], + selectedSpecialists: [], + localValidation: { + commands: [], + prerequisites: [], + notes: [summary] + }, + ciValidation: { + workflowFiles: [], + jobs: [], + notes: [summary] + }, + coverage: { + confidence: "low", + summary, + signals: [], + gaps: [summary] + }, + recommendedChanges: ["Re-run deliver after resolving the validation-stage failure."], + unsupported: [summary], + pyramidMarkdown: "# Test Pyramid\n\nValidation stage failed before test layers could be planned.\n", + reportMarkdown: `# Validation Summary\n\n${summary}\n`, + githubActionsPlanMarkdown: "# GitHub Actions Validation Plan\n\nValidation could not be planned due to an execution failure.\n" + }, + localValidationRecord: { + status: "not-run", + requestedCommands: [], + results: [], + notes: summary + }, + coverageSummary: { + status: "blocked", + outcomeCategory: "blocked-by-validation", + confidence: "low", + summary, + signals: [], + gaps: [summary], + localValidationStatus: "not-run" + }, + selectedSpecialists: [], + specialistExecutions: [], + finalBody: `# Validation Summary\n\n${summary}\n` + }; +} + +function createFailedReviewVerdict(error: string): DeliverReviewVerdict { + return { + mode: "readiness", + status: "blocked", + summary: `Review stage failed: ${error}`, + findings: [ + { + severity: "high", + title: "Review stage failed", + detail: error + } + ], + recommendedActions: ["Inspect the review stage error details and rerun deliver."], + acceptedSpecialists: [], + reportMarkdown: `# Review Findings\n\n${error}\n` + }; +} + +function createFailedShipRecord(error: string, readynessMessage: string): DeliverShipRecord { + return { + readiness: "blocked", + summary: `Ship stage failed: ${error}`, + checklist: [ + { + item: readynessMessage, + status: "blocked", + notes: error + } + ], + unresolved: [`${readynessMessage}: ${error}`], + nextActions: ["Inspect the ship-stage error details and rerun deliver once fixed."], + reportMarkdown: `# Ship Summary\n\nShip stage failed: ${error}\n` + }; +} + +function stageByName(stageLineage: StageLineage, name: RoutingStagePlan["name"]): RoutingStagePlan { + const stage = stageLineage.stages.find((entry) => entry.name === name); + if (!stage) { + throw new Error(`Stage not found in deliver lineage: ${name}`); + } + return stage; +} + +async function writeValidationArtifacts({ + validationStageDir, + validationExecution +}: { + validationStageDir: string; + validationExecution: DeliverValidationExecutionResult; +}): Promise<void> { + await fs.mkdir(path.join(validationStageDir, "artifacts"), { recursive: true }); + await fs.writeFile(path.join(validationStageDir, "final.md"), validationExecution.finalBody, "utf8"); + await writeJson(path.join(validationStageDir, "repo-profile.json"), validationExecution.repoProfile); + await writeJson(path.join(validationStageDir, "tool-research.json"), validationExecution.toolResearch); + await writeJson(path.join(validationStageDir, "validation-plan.json"), validationExecution.validationPlan); + await fs.writeFile(path.join(validationStageDir, "artifacts", "test-pyramid.md"), validationExecution.validationPlan.pyramidMarkdown, "utf8"); + await writeJson(path.join(validationStageDir, "artifacts", "coverage-summary.json"), validationExecution.coverageSummary); + await fs.writeFile( + path.join(validationStageDir, "artifacts", "coverage-gaps.md"), + `# Coverage Gaps\n\n- ${validationExecution.coverageSummary.gaps.join("\n- ") || "No data was collected."}\n`, + "utf8" + ); + await writeJson(path.join(validationStageDir, "artifacts", "local-validation.json"), validationExecution.localValidationRecord); + await writeJson( + path.join(validationStageDir, "artifacts", "ci-validation.json"), + validationExecution.validationPlan.ciValidation + ); + await fs.writeFile( + path.join(validationStageDir, "artifacts", "github-actions-plan.md"), + validationExecution.validationPlan.githubActionsPlanMarkdown, + "utf8" + ); + await writeJson(path.join(validationStageDir, "artifacts", "test-inventory.json"), { status: "blocked", tests: [] }); +} + +async function writeReviewArtifacts({ + reviewStageDir, + reviewVerdict +}: { + reviewStageDir: string; + reviewVerdict: DeliverReviewVerdict; +}): Promise<void> { + await fs.mkdir(path.join(reviewStageDir, "artifacts"), { recursive: true }); + await writeJson(path.join(reviewStageDir, "artifacts", "findings.json"), { + findings: reviewVerdict.findings, + recommendedActions: reviewVerdict.recommendedActions, + acceptedSpecialists: reviewVerdict.acceptedSpecialists + }); + await fs.writeFile(path.join(reviewStageDir, "artifacts", "verdict.json"), `${JSON.stringify(reviewVerdict, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(reviewStageDir, "final.md"), `${JSON.stringify(reviewVerdict, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(reviewStageDir, "artifacts", "findings.md"), reviewVerdict.reportMarkdown, "utf8"); +} + +async function writeShipArtifacts({ + shipStageDir, + shipRecord, + githubDeliveryRecord, + readinessPolicyRecord, + deploymentEvidenceRecord +}: { + shipStageDir: string; + shipRecord: DeliverShipRecord; + githubDeliveryRecord: GitHubDeliveryRecord; + readinessPolicyRecord: DeliveryReadinessPolicyRecord; + deploymentEvidenceRecord: DeploymentEvidenceRecord; +}): Promise<void> { + await fs.mkdir(path.join(shipStageDir, "artifacts"), { recursive: true }); + await fs.writeFile(path.join(shipStageDir, "final.md"), `${JSON.stringify(shipRecord, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(shipStageDir, "artifacts", "ship-summary.md"), shipRecord.reportMarkdown, "utf8"); + await fs.writeFile(path.join(shipStageDir, "artifacts", "release-checklist.md"), renderChecklistMarkdown(shipRecord), "utf8"); + await fs.writeFile(path.join(shipStageDir, "artifacts", "unresolved.md"), renderUnresolvedMarkdown(shipRecord), "utf8"); + await writeJson(path.join(shipStageDir, "artifacts", "ship-record.json"), shipRecord); + await writeJson(path.join(shipStageDir, "artifacts", "readiness-policy.json"), readinessPolicyRecord); + await writeJson(path.join(shipStageDir, "artifacts", "deployment-evidence.json"), deploymentEvidenceRecord); + await writeJson(path.join(shipStageDir, "artifacts", "github-state.json"), { status: "blocked", summary: githubDeliveryRecord.overall.summary }); + await writeJson(path.join(shipStageDir, "artifacts", "pull-request.json"), githubDeliveryRecord.pullRequest); + await writeJson(path.join(shipStageDir, "artifacts", "issues.json"), githubDeliveryRecord.issues); + await writeJson(path.join(shipStageDir, "artifacts", "checks.json"), githubDeliveryRecord.checks); + await writeJson(path.join(shipStageDir, "artifacts", "actions.json"), githubDeliveryRecord.actions); + await writeJson(path.join(shipStageDir, "artifacts", "security.json"), githubDeliveryRecord.security); + await writeJson(path.join(shipStageDir, "artifacts", "release.json"), githubDeliveryRecord.release); +} + +async function writePostShipArtifacts(options: { + artifactsDir: string; + summaryMarkdown: string; + evidenceRecord: object; + followUpDraftMarkdown: string; + followUpRecord: object; +}): Promise<void> { + await fs.mkdir(options.artifactsDir, { recursive: true }); + await fs.writeFile(path.join(options.artifactsDir, "post-ship-summary.md"), options.summaryMarkdown, "utf8"); + await writeJson(path.join(options.artifactsDir, "post-ship-evidence.json"), options.evidenceRecord); + await fs.writeFile(path.join(options.artifactsDir, "follow-up-draft.md"), options.followUpDraftMarkdown, "utf8"); + await writeJson(path.join(options.artifactsDir, "follow-up-lineage.json"), options.followUpRecord); +} + +function summarizeFailureStageError(error: unknown): string { + return summarizeOrThrow(error); +} + +function createBlockedDeploymentEvidenceRecord(deliveryMode: DeliverTargetMode, summary: string): DeploymentEvidenceRecord { + return { + mode: deliveryMode, + generatedAt: new Date().toISOString(), + summary, + blockers: [summary], + references: [], + status: "missing" + }; +} + +function createBlockedReadinessPolicyRecord(options: { + deliveryMode: DeliverTargetMode; + shipRecord: DeliverShipRecord; + githubDeliveryRecord: GitHubDeliveryRecord; + summary: string; +}): DeliveryReadinessPolicyRecord { + return { + mode: options.deliveryMode, + readiness: options.shipRecord.readiness, + generatedAt: new Date().toISOString(), + summary: options.summary, + blockers: [options.summary], + requirements: [ + { + name: "ship-readiness", + required: true, + status: "blocked", + summary: options.shipRecord.summary, + evidence: [options.shipRecord.summary] + }, + { + name: "github-delivery", + required: true, + status: options.githubDeliveryRecord.overall.status === "ready" ? "satisfied" : "blocked", + summary: options.githubDeliveryRecord.overall.summary, + evidence: [...options.githubDeliveryRecord.overall.blockers] + }, + { + name: "deployment-evidence", + required: options.deliveryMode === "release", + status: options.deliveryMode === "release" ? "missing" : "not-applicable", + summary: options.summary, + evidence: [] + } + ], + classifiedBlockers: [ + { + category: "ship-output", + requirement: "ship-readiness", + status: "blocked", + summary: options.shipRecord.summary, + evidence: [options.shipRecord.summary] + }, + ...(options.githubDeliveryRecord.overall.status === "ready" + ? [] + : [ + { + category: "github-delivery" as const, + requirement: "github-delivery" as const, + status: "blocked" as const, + summary: options.githubDeliveryRecord.overall.summary, + evidence: [...options.githubDeliveryRecord.overall.blockers] + } + ]), + ...(options.deliveryMode === "release" + ? [ + { + category: "deployment-evidence" as const, + requirement: "deployment-evidence" as const, + status: "missing" as const, + summary: options.summary, + evidence: [] + } + ] + : []) + ], + postReadinessSummary: { + status: options.shipRecord.readiness, + headline: options.summary, + highlights: [`Ship readiness: ${options.shipRecord.readiness}`], + blockers: [ + `ship-output: ${options.shipRecord.summary}`, + ...(options.githubDeliveryRecord.overall.status === "ready" + ? [] + : [`github-delivery: ${options.githubDeliveryRecord.overall.summary}`]), + ...(options.deliveryMode === "release" ? [`deployment-evidence: ${options.summary}`] : []) + ], + nextActions: options.shipRecord.nextActions.slice(0, 8) + } + }; +} + export async function runDeliverExecution(options: DeliverExecutionOptions): Promise<DeliverExecutionResult> { const selectedSpecialists = selectDeliverSpecialists(options.input); const { prompt, context } = await buildDeliverPrompt({ @@ -604,12 +949,19 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro const events = createDeliverEventRecorder(options.runId, options.paths.eventsPath); events.setSpecialists(selectedSpecialists.map((specialist) => specialist.name)); await events.emit("starting", "Running deliver workflow across build -> validation -> review -> ship"); - + const persistLineage = async (): Promise<void> => writeJson(options.paths.stageLineagePath, stageLineage); const buildStageDir = deliverStageDir(options.paths.runDir, "build"); + const validationStageDir = deliverStageDir(options.paths.runDir, "validation"); + const reviewStageDir = deliverStageDir(options.paths.runDir, "review"); + const shipStageDir = deliverStageDir(options.paths.runDir, "ship"); await fs.mkdir(path.join(buildStageDir, "artifacts"), { recursive: true }); + await fs.mkdir(path.join(validationStageDir, "artifacts"), { recursive: true }); + await fs.mkdir(path.join(reviewStageDir, "artifacts"), { recursive: true }); + await fs.mkdir(path.join(shipStageDir, "artifacts"), { recursive: true }); + try { const buildStage = stageLineage.stages.find((stage) => stage.name === "build")!; buildStage.status = "running"; - await writeJson(options.paths.stageLineagePath, stageLineage); + await persistLineage(); events.markStage("build", "running"); const buildExecution = await runBuildExecution({ @@ -648,13 +1000,11 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro } else { delete buildStage.notes; } - await writeJson(options.paths.stageLineagePath, stageLineage); + await persistLineage(); events.markStage("build", buildExecution.result.code === 0 ? "completed" : "failed"); + if (buildExecution.result.code !== 0) { const buildFailureSummary = summarizeBuildFailure(buildExecution); - const validationStageDir = deliverStageDir(options.paths.runDir, "validation"); - const reviewStageDir = deliverStageDir(options.paths.runDir, "review"); - const shipStageDir = deliverStageDir(options.paths.runDir, "ship"); const validationExecution = createBlockedValidationExecution(buildExecution); const reviewVerdict = createBlockedReviewVerdict(buildExecution); const shipRecord = createBlockedShipRecord(buildExecution); @@ -666,29 +1016,47 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro config: options.config, githubMutationRecord }); + const deploymentEvidenceRecord = createBlockedDeploymentEvidenceRecord( + options.deliveryMode, + "Deployment evidence was not collected because build failed before ship readiness evaluation." + ); + const readinessPolicyRecord = createBlockedReadinessPolicyRecord({ + deliveryMode: options.deliveryMode, + shipRecord, + githubDeliveryRecord, + summary: "Readiness policy evaluation was blocked because build failed before ship readiness evaluation." + }); + const postShipArtifacts = buildPostShipArtifacts({ + runId: options.runId, + workflow: "deliver", + shipRecord, + githubDeliveryRecord + }); - const validationStage = stageLineage.stages.find((stage) => stage.name === "validation")!; + const validationStage = stageByName(stageLineage, "validation"); validationStage.status = "deferred"; validationStage.executed = false; validationStage.stageDir = validationStageDir; validationStage.artifactPath = path.join(validationStageDir, "artifacts", "test-pyramid.md"); validationStage.notes = `Blocked because build failed. ${buildFailureSummary}`; - events.markStage("validation", "deferred"); - const reviewStage = stageLineage.stages.find((stage) => stage.name === "review")!; + const reviewStage = stageByName(stageLineage, "review"); reviewStage.status = "deferred"; reviewStage.executed = false; reviewStage.stageDir = reviewStageDir; reviewStage.artifactPath = path.join(reviewStageDir, "artifacts", "findings.md"); reviewStage.notes = `Blocked because build failed. ${buildFailureSummary}`; - events.markStage("review", "deferred"); - const shipStage = stageLineage.stages.find((stage) => stage.name === "ship")!; + const shipStage = stageByName(stageLineage, "ship"); shipStage.status = "deferred"; shipStage.executed = false; shipStage.stageDir = shipStageDir; shipStage.artifactPath = path.join(shipStageDir, "artifacts", "ship-summary.md"); shipStage.notes = `Blocked because build failed. ${buildFailureSummary}`; + await persistLineage(); + + events.markStage("validation", "deferred"); + events.markStage("review", "deferred"); events.markStage("ship", "deferred"); await writeBlockedDeliverStageArtifacts({ @@ -698,14 +1066,30 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro validationExecution, reviewVerdict, shipRecord, + readinessPolicyRecord, + deploymentEvidenceRecord, githubMutationRecord, githubDeliveryRecord }); await writeJson(path.join(options.paths.runDir, "artifacts", "github-mutation.json"), githubMutationRecord); await writeJson(path.join(options.paths.runDir, "artifacts", "github-delivery.json"), githubDeliveryRecord); - await writeJson(options.paths.stageLineagePath, stageLineage); + await writeJson(path.join(options.paths.runDir, "artifacts", "readiness-policy.json"), readinessPolicyRecord); + await writeJson(path.join(options.paths.runDir, "artifacts", "deployment-evidence.json"), deploymentEvidenceRecord); + await writePostShipArtifacts({ + artifactsDir: path.join(shipStageDir, "artifacts"), + summaryMarkdown: postShipArtifacts.summaryMarkdown, + evidenceRecord: postShipArtifacts.evidenceRecord, + followUpDraftMarkdown: postShipArtifacts.followUpDraftMarkdown, + followUpRecord: postShipArtifacts.followUpRecord + }); + await writePostShipArtifacts({ + artifactsDir: path.join(options.paths.runDir, "artifacts"), + summaryMarkdown: postShipArtifacts.summaryMarkdown, + evidenceRecord: postShipArtifacts.evidenceRecord, + followUpDraftMarkdown: postShipArtifacts.followUpDraftMarkdown, + followUpRecord: postShipArtifacts.followUpRecord + }); await events.emit("failed", `Build failed; downstream stages blocked. ${buildFailureSummary}`); - events.close(); const finalBody = buildDeliverFinalSummary({ input: options.input, @@ -715,6 +1099,7 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro shipRecord, githubMutationRecord, githubDeliveryRecord, + readinessPolicyRecord, ...(options.linkedContext ? { linkedContext: options.linkedContext } : {}) }); await fs.writeFile(options.paths.finalPath, finalBody, "utf8"); @@ -734,264 +1119,412 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro } await events.emit("activity", "Build stage finished, starting validation synthesis"); - const validationStageDir = deliverStageDir(options.paths.runDir, "validation"); - await fs.mkdir(path.join(validationStageDir, "artifacts"), { recursive: true }); - const validationStage = stageLineage.stages.find((stage) => stage.name === "validation")!; + const validationStage = stageByName(stageLineage, "validation"); validationStage.status = "running"; - await writeJson(options.paths.stageLineagePath, stageLineage); + validationStage.stageDir = validationStageDir; + validationStage.artifactPath = path.join(validationStageDir, "artifacts", "test-pyramid.md"); + await persistLineage(); events.markStage("validation", "running"); - const validationExecution = await runDeliverValidationExecution({ - cwd: options.cwd, - runId: options.runId, - input: options.input, - config: options.config, - paths: { - stageDir: validationStageDir, - promptPath: path.join(validationStageDir, "prompt.md"), - contextPath: path.join(validationStageDir, "context.md"), - finalPath: path.join(validationStageDir, "final.md"), - eventsPath: path.join(validationStageDir, "events.jsonl"), - stdoutPath: path.join(validationStageDir, "stdout.log"), - stderrPath: path.join(validationStageDir, "stderr.log"), - repoProfilePath: path.join(validationStageDir, "repo-profile.json"), - validationPlanPath: path.join(validationStageDir, "validation-plan.json"), - toolResearchPath: path.join(validationStageDir, "tool-research.json"), - testPyramidPath: path.join(validationStageDir, "artifacts", "test-pyramid.md"), - coverageSummaryPath: path.join(validationStageDir, "artifacts", "coverage-summary.json"), - coverageGapsPath: path.join(validationStageDir, "artifacts", "coverage-gaps.md"), - localValidationPath: path.join(validationStageDir, "artifacts", "local-validation.json"), - ciValidationPath: path.join(validationStageDir, "artifacts", "ci-validation.json"), - githubActionsPlanPath: path.join(validationStageDir, "artifacts", "github-actions-plan.md"), - testInventoryPath: path.join(validationStageDir, "artifacts", "test-inventory.json") - }, - buildSummary: buildExecution.finalBody, - buildVerificationRecord: buildExecution.verificationRecord - }); + let validationExecution = createFailedValidationExecution("Validation stage did not run."); + try { + validationExecution = await runDeliverValidationExecution({ + cwd: options.cwd, + runId: options.runId, + input: options.input, + config: options.config, + paths: { + stageDir: validationStageDir, + promptPath: path.join(validationStageDir, "prompt.md"), + contextPath: path.join(validationStageDir, "context.md"), + finalPath: path.join(validationStageDir, "final.md"), + eventsPath: path.join(validationStageDir, "events.jsonl"), + stdoutPath: path.join(validationStageDir, "stdout.log"), + stderrPath: path.join(validationStageDir, "stderr.log"), + repoProfilePath: path.join(validationStageDir, "repo-profile.json"), + validationPlanPath: path.join(validationStageDir, "validation-plan.json"), + toolResearchPath: path.join(validationStageDir, "tool-research.json"), + testPyramidPath: path.join(validationStageDir, "artifacts", "test-pyramid.md"), + coverageSummaryPath: path.join(validationStageDir, "artifacts", "coverage-summary.json"), + coverageGapsPath: path.join(validationStageDir, "artifacts", "coverage-gaps.md"), + localValidationPath: path.join(validationStageDir, "artifacts", "local-validation.json"), + ciValidationPath: path.join(validationStageDir, "artifacts", "ci-validation.json"), + githubActionsPlanPath: path.join(validationStageDir, "artifacts", "github-actions-plan.md"), + testInventoryPath: path.join(validationStageDir, "artifacts", "test-inventory.json") + }, + buildSummary: buildExecution.finalBody, + buildVerificationRecord: buildExecution.verificationRecord + }); + } catch (error) { + const message = summarizeFailureStageError(error); + validationExecution = createFailedValidationExecution(message); + await events.emit("failed", `Validation stage failed. ${message}`); + } - stageLineage.specialists.push(...validationExecution.specialistExecutions); - validationStage.status = - validationExecution.validationPlan.status === "ready" - ? "completed" - : validationExecution.validationPlan.status === "partial" - ? "deferred" - : "failed"; - validationStage.executed = true; - validationStage.stageDir = validationStageDir; - validationStage.artifactPath = path.join(validationStageDir, "artifacts", "test-pyramid.md"); - validationStage.notes = validationExecution.validationPlan.summary; - await writeJson(options.paths.stageLineagePath, stageLineage); + if (validationExecution) { + validationStage.status = + validationExecution.validationPlan.status === "ready" + ? "completed" + : validationExecution.validationPlan.status === "partial" + ? "deferred" + : "failed"; + validationStage.executed = true; + validationStage.notes = `${validationExecution.validationPlan.outcomeCategory}: ${validationExecution.validationPlan.summary}`; + stageLineage.specialists.push(...validationExecution.specialistExecutions); + } else { + validationStage.status = "failed"; + validationStage.executed = true; + validationStage.notes = "Validation stage did not produce a record."; + } + await persistLineage(); events.markStage( "validation", validationStage.status === "completed" ? "completed" : validationStage.status === "deferred" ? "deferred" : "failed" ); + await writeValidationArtifacts({ validationStageDir, validationExecution }); await events.emit("activity", "Validation stage finished, starting review synthesis"); - const reviewStageDir = deliverStageDir(options.paths.runDir, "review"); - await fs.mkdir(path.join(reviewStageDir, "artifacts"), { recursive: true }); - const reviewStage = stageLineage.stages.find((stage) => stage.name === "review")!; + const reviewStage = stageByName(stageLineage, "review"); reviewStage.status = "running"; - await writeJson(options.paths.stageLineagePath, stageLineage); + reviewStage.stageDir = reviewStageDir; + reviewStage.artifactPath = path.join(reviewStageDir, "artifacts", "findings.md"); + await persistLineage(); events.markStage("review", "running"); const specialistResults: Array<{ name: SpecialistSelection["name"]; reason: string; finalBody: string }> = []; - for (const specialist of selectedSpecialists) { - events.markSpecialist(specialist.name, "running"); - const result = await runDeliverSpecialist({ + let reviewVerdict = createFailedReviewVerdict("Review stage did not run."); + try { + let reviewResultCode = 1; + for (const specialist of selectedSpecialists) { + events.markSpecialist(specialist.name, "running"); + const result = await runDeliverSpecialist({ + cwd: options.cwd, + runId: options.runId, + stageDir: reviewStageDir, + input: options.input, + specialist, + config: options.config, + buildSummary: buildExecution.finalBody, + verificationRecord: buildExecution.verificationRecord + }); + stageLineage.specialists.push(result.execution); + specialistResults.push({ + name: specialist.name, + reason: specialist.reason, + finalBody: result.finalBody + }); + events.markSpecialist(specialist.name, result.execution.status === "completed" ? "completed" : "failed"); + } + + const reviewLeadPrompt = await buildDeliverReviewLeadPrompt({ cwd: options.cwd, - runId: options.runId, - stageDir: reviewStageDir, input: options.input, - specialist, - config: options.config, + mode: "readiness", buildSummary: buildExecution.finalBody, - verificationRecord: buildExecution.verificationRecord + verificationRecord: buildExecution.verificationRecord, + validationPlan: validationExecution.validationPlan, + validationLocalRecord: validationExecution.localValidationRecord, + specialistResults }); - stageLineage.specialists.push(result.execution); - specialistResults.push({ - name: specialist.name, - reason: specialist.reason, - finalBody: result.finalBody + await fs.writeFile(path.join(reviewStageDir, "prompt.md"), reviewLeadPrompt.prompt, "utf8"); + await fs.writeFile(path.join(reviewStageDir, "context.md"), `${reviewLeadPrompt.context}\n`, "utf8"); + + const reviewResult = await runCodexExec({ + cwd: options.cwd, + workflow: "deliver", + runId: `${options.runId}-review`, + prompt: reviewLeadPrompt.prompt, + finalPath: path.join(reviewStageDir, "final.md"), + eventsPath: path.join(reviewStageDir, "events.jsonl"), + stdoutPath: path.join(reviewStageDir, "stdout.log"), + stderrPath: path.join(reviewStageDir, "stderr.log"), + config: options.config, + ...(typeof options.reviewTimeoutSeconds === "number" ? { timeoutSeconds: options.reviewTimeoutSeconds } : {}) }); - events.markSpecialist(specialist.name, result.execution.status === "completed" ? "completed" : "failed"); - } + reviewResultCode = reviewResult.code; + const reviewRaw = await readCodexFinalOutput({ + context: "Review lead", + finalPath: path.join(reviewStageDir, "final.md"), + stdoutPath: path.join(reviewStageDir, "stdout.log"), + stderrPath: path.join(reviewStageDir, "stderr.log"), + result: reviewResult + }); + reviewVerdict = parseJson<DeliverReviewVerdict>(reviewRaw, "Review lead"); - const reviewLeadPrompt = await buildDeliverReviewLeadPrompt({ - cwd: options.cwd, - input: options.input, - mode: "readiness", - buildSummary: buildExecution.finalBody, - verificationRecord: buildExecution.verificationRecord, - validationPlan: validationExecution.validationPlan, - validationLocalRecord: validationExecution.localValidationRecord, - specialistResults - }); - await fs.writeFile(path.join(reviewStageDir, "prompt.md"), reviewLeadPrompt.prompt, "utf8"); - await fs.writeFile(path.join(reviewStageDir, "context.md"), `${reviewLeadPrompt.context}\n`, "utf8"); + const acceptedByName = new Map(reviewVerdict.acceptedSpecialists.map((entry) => [entry.name, entry])); + stageLineage.specialists = stageLineage.specialists.map((execution) => { + const accepted = acceptedByName.get(execution.name); + return accepted + ? { + ...execution, + disposition: accepted.disposition, + notes: accepted.reason + } + : { + ...execution, + disposition: "discarded", + notes: execution.notes ?? "The review lead did not rely on this specialist output." + }; + }); + } catch (error) { + reviewVerdict = createFailedReviewVerdict(summarizeFailureStageError(error)); + await events.emit("failed", `Review stage failed. ${reviewVerdict.summary}`); + } - const reviewResult = await runCodexExec({ - cwd: options.cwd, - workflow: "deliver", - runId: `${options.runId}-review`, - prompt: reviewLeadPrompt.prompt, - finalPath: path.join(reviewStageDir, "final.md"), - eventsPath: path.join(reviewStageDir, "events.jsonl"), - stdoutPath: path.join(reviewStageDir, "stdout.log"), - stderrPath: path.join(reviewStageDir, "stderr.log"), - config: options.config, - ...(typeof options.reviewTimeoutSeconds === "number" ? { timeoutSeconds: options.reviewTimeoutSeconds } : {}) - }); - const reviewRaw = await fs.readFile(path.join(reviewStageDir, "final.md"), "utf8"); - const reviewVerdict = parseJson<DeliverReviewVerdict>(reviewRaw, "Review lead"); - await fs.writeFile(path.join(reviewStageDir, "artifacts", "findings.md"), reviewVerdict.reportMarkdown, "utf8"); - await writeJson(path.join(reviewStageDir, "artifacts", "findings.json"), { - findings: reviewVerdict.findings, - recommendedActions: reviewVerdict.recommendedActions, - acceptedSpecialists: reviewVerdict.acceptedSpecialists - }); - await writeJson(path.join(reviewStageDir, "artifacts", "verdict.json"), reviewVerdict); - - const acceptedByName = new Map(reviewVerdict.acceptedSpecialists.map((entry) => [entry.name, entry])); - stageLineage.specialists = stageLineage.specialists.map((execution) => { - const accepted = acceptedByName.get(execution.name); - return accepted - ? { - ...execution, - disposition: accepted.disposition, - notes: accepted.reason - } - : { - ...execution, - disposition: "discarded", - notes: execution.notes ?? "The review lead did not rely on this specialist output." - }; - }); - reviewStage.status = reviewResult.code === 0 ? "completed" : "failed"; + reviewStage.status = reviewVerdict.status === "ready" ? "completed" : "failed"; reviewStage.executed = true; - reviewStage.stageDir = reviewStageDir; - reviewStage.artifactPath = path.join(reviewStageDir, "artifacts", "findings.md"); reviewStage.notes = reviewVerdict.summary; - await writeJson(options.paths.stageLineagePath, stageLineage); - events.markStage("review", reviewResult.code === 0 ? "completed" : "failed"); + await persistLineage(); + await writeReviewArtifacts({ reviewStageDir, reviewVerdict }); + events.markStage("review", reviewStage.status === "completed" ? "completed" : "failed"); await events.emit("activity", "Review stage finished, preparing ship readiness artifacts"); - const shipStageDir = deliverStageDir(options.paths.runDir, "ship"); - await fs.mkdir(path.join(shipStageDir, "artifacts"), { recursive: true }); - const shipStage = stageLineage.stages.find((stage) => stage.name === "ship")!; + const shipStage = stageByName(stageLineage, "ship"); shipStage.status = "running"; - await writeJson(options.paths.stageLineagePath, stageLineage); + shipStage.stageDir = shipStageDir; + shipStage.artifactPath = path.join(shipStageDir, "artifacts", "ship-summary.md"); + await persistLineage(); events.markStage("ship", "running"); - const githubMutation = await performGitHubDeliverMutations({ - cwd: options.cwd, + let githubMutation = createBlockedGitHubMutationRecord(); + let githubMutationResult: PerformGitHubMutationResult = { + branch: options.gitBranch, + record: githubMutation + }; + let githubDeliveryRecord = createBlockedGitHubDeliveryRecord({ gitBranch: options.gitBranch, - runId: options.runId, - input: options.input, - issueNumbers: options.issueNumbers, - policy: options.config.workflows.deliver.github ?? {}, - buildSummary: buildExecution.finalBody, - reviewVerdict, - verificationRecord: buildExecution.verificationRecord, - ...(options.linkedContext?.run.id ? { linkedRunId: options.linkedContext.run.id } : {}), - pullRequestBodyPath: path.join(shipStageDir, "artifacts", "pull-request-body.md") - }); - - const githubEvidence = await collectGitHubDeliveryEvidence({ - cwd: options.cwd, - gitBranch: githubMutation.branch, deliveryMode: options.deliveryMode, issueNumbers: options.issueNumbers, - policy: options.config.workflows.deliver.github ?? {}, - input: options.input, - mutationRecord: githubMutation.record, - ...(options.linkedContext?.artifactBody ? { linkedArtifactBody: options.linkedContext.artifactBody } : {}) - }); - const githubDeliveryRecord = githubEvidence.record; - const shipPrompt = await buildDeliverShipPrompt({ - cwd: options.cwd, - input: options.input, - buildSummary: buildExecution.finalBody, - validationPlan: validationExecution.validationPlan, - validationLocalRecord: validationExecution.localValidationRecord, - reviewVerdict, - verificationRecord: buildExecution.verificationRecord, - githubMutationRecord: githubMutation.record, - githubDeliveryRecord - }); - await fs.writeFile(path.join(shipStageDir, "prompt.md"), shipPrompt.prompt, "utf8"); - await fs.writeFile(path.join(shipStageDir, "context.md"), `${shipPrompt.context}\n`, "utf8"); - const shipResult = await runCodexExec({ - cwd: options.cwd, - workflow: "deliver", - runId: `${options.runId}-ship`, - prompt: shipPrompt.prompt, - finalPath: path.join(shipStageDir, "final.md"), - eventsPath: path.join(shipStageDir, "events.jsonl"), - stdoutPath: path.join(shipStageDir, "stdout.log"), - stderrPath: path.join(shipStageDir, "stderr.log"), config: options.config, - ...(typeof options.shipTimeoutSeconds === "number" ? { timeoutSeconds: options.shipTimeoutSeconds } : {}) + githubMutationRecord: githubMutation }); - const shipRaw = await fs.readFile(path.join(shipStageDir, "final.md"), "utf8"); - let shipRecord = parseJson<DeliverShipRecord>(shipRaw, "Ship lead"); - if (githubDeliveryRecord.overall.status === "blocked") { - shipRecord = { - ...shipRecord, - readiness: "blocked", - summary: `${shipRecord.summary} GitHub delivery is blocked.`, - unresolved: mergeUniqueLines([...shipRecord.unresolved, ...githubDeliveryRecord.overall.blockers]), - nextActions: mergeUniqueLines([...shipRecord.nextActions, ...githubDeliveryRecord.overall.blockers]), - checklist: [ - ...shipRecord.checklist, - { - item: "GitHub delivery policy", - status: "blocked", - notes: githubDeliveryRecord.overall.blockers.join("; ") - } - ], - reportMarkdown: `${shipRecord.reportMarkdown.trimEnd()}\n\n## GitHub delivery\n\nStatus: blocked\n` + let deploymentEvidenceRecord = createBlockedDeploymentEvidenceRecord( + options.deliveryMode, + "Deployment evidence was not collected because ship readiness did not run." + ); + let shipRecord = createFailedShipRecord("Ship stage did not run.", "Deliver artifacts were not generated."); + let readinessPolicyRecord = createBlockedReadinessPolicyRecord({ + deliveryMode: options.deliveryMode, + shipRecord, + githubDeliveryRecord, + summary: "Readiness policy evaluation did not run." + }); + + try { + githubMutationResult = await performGitHubDeliverMutations({ + cwd: options.cwd, + gitBranch: options.gitBranch, + runId: options.runId, + input: options.input, + issueNumbers: options.issueNumbers, + policy: options.config.workflows.deliver.github ?? {}, + buildSummary: buildExecution.finalBody, + reviewVerdict, + verificationRecord: buildExecution.verificationRecord, + ...(options.linkedContext?.run.id ? { linkedRunId: options.linkedContext.run.id } : {}), + pullRequestBodyPath: path.join(shipStageDir, "artifacts", "pull-request-body.md") + }); + + const githubEvidence = await collectGitHubDeliveryEvidence({ + cwd: options.cwd, + gitBranch: githubMutationResult.record.branch.current, + deliveryMode: options.deliveryMode, + issueNumbers: options.issueNumbers, + policy: options.config.workflows.deliver.github ?? {}, + input: options.input, + mutationRecord: githubMutationResult.record, + ...(options.linkedContext?.artifactBody ? { linkedArtifactBody: options.linkedContext.artifactBody } : {}) + }); + githubDeliveryRecord = githubEvidence.record; + deploymentEvidenceRecord = buildDeploymentEvidenceRecord({ + deliveryMode: options.deliveryMode, + githubDeliveryRecord + }); + + const shipPrompt = await buildDeliverShipPrompt({ + cwd: options.cwd, + input: options.input, + buildSummary: buildExecution.finalBody, + validationPlan: validationExecution.validationPlan, + validationLocalRecord: validationExecution.localValidationRecord, + reviewVerdict, + verificationRecord: buildExecution.verificationRecord, + githubMutationRecord: githubMutationResult.record, + githubDeliveryRecord + }); + await fs.writeFile(path.join(shipStageDir, "prompt.md"), shipPrompt.prompt, "utf8"); + await fs.writeFile(path.join(shipStageDir, "context.md"), `${shipPrompt.context}\n`, "utf8"); + const shipResult = await runCodexExec({ + cwd: options.cwd, + workflow: "deliver", + runId: `${options.runId}-ship`, + prompt: shipPrompt.prompt, + finalPath: path.join(shipStageDir, "final.md"), + eventsPath: path.join(shipStageDir, "events.jsonl"), + stdoutPath: path.join(shipStageDir, "stdout.log"), + stderrPath: path.join(shipStageDir, "stderr.log"), + config: options.config, + ...(typeof options.shipTimeoutSeconds === "number" ? { timeoutSeconds: options.shipTimeoutSeconds } : {}) + }); + const shipRaw = await readCodexFinalOutput({ + context: "Ship lead", + finalPath: path.join(shipStageDir, "final.md"), + stdoutPath: path.join(shipStageDir, "stdout.log"), + stderrPath: path.join(shipStageDir, "stderr.log"), + result: shipResult + }); + let parsedShipRecord = parseJson<DeliverShipRecord>(shipRaw, "Ship lead"); + + if (githubDeliveryRecord.overall.status === "blocked") { + parsedShipRecord = { + ...parsedShipRecord, + readiness: "blocked", + summary: `${parsedShipRecord.summary} GitHub delivery is blocked.`, + unresolved: mergeUniqueLines([...parsedShipRecord.unresolved, ...githubDeliveryRecord.overall.blockers]), + nextActions: mergeUniqueLines([...parsedShipRecord.nextActions, ...githubDeliveryRecord.overall.blockers]), + checklist: [ + ...parsedShipRecord.checklist, + { + item: "GitHub delivery policy", + status: "blocked", + notes: githubDeliveryRecord.overall.blockers.join("; ") + } + ], + reportMarkdown: `${parsedShipRecord.reportMarkdown.trimEnd()}\n\n## GitHub delivery\n\nStatus: blocked\n` + }; + } else { + parsedShipRecord = { + ...parsedShipRecord, + checklist: [ + ...parsedShipRecord.checklist, + { + item: "GitHub delivery policy", + status: "complete", + notes: githubDeliveryRecord.overall.summary + } + ], + reportMarkdown: `${parsedShipRecord.reportMarkdown.trimEnd()}\n\n## GitHub delivery\n\nStatus: ready\n` + }; + } + shipRecord = parsedShipRecord; + + await writeJson(path.join(shipStageDir, "artifacts", "github-state.json"), githubEvidence.artifacts.githubState); + await writeJson(path.join(shipStageDir, "artifacts", "pull-request.json"), githubEvidence.artifacts.pullRequest); + await writeJson(path.join(shipStageDir, "artifacts", "issues.json"), githubEvidence.artifacts.issues); + await writeJson(path.join(shipStageDir, "artifacts", "checks.json"), githubEvidence.artifacts.checks); + await writeJson(path.join(shipStageDir, "artifacts", "actions.json"), githubEvidence.artifacts.actions); + await writeJson(path.join(shipStageDir, "artifacts", "security.json"), githubEvidence.artifacts.security); + await writeJson(path.join(shipStageDir, "artifacts", "release.json"), githubEvidence.artifacts.release); + await writeJson(path.join(shipStageDir, "artifacts", "github-mutation.json"), githubMutationResult.record); + await writeJson(path.join(options.paths.runDir, "artifacts", "github-mutation.json"), githubMutationResult.record); + await writeJson(path.join(options.paths.runDir, "artifacts", "github-delivery.json"), githubDeliveryRecord); + + if (shipResult.code !== 0) { + shipRecord = { + ...shipRecord, + readiness: "blocked", + summary: `${shipRecord.summary} Ship lead command exited with code ${shipResult.code}.`, + unresolved: mergeUniqueLines([...shipRecord.unresolved, `Ship lead command exited with code ${shipResult.code}.`]), + nextActions: mergeUniqueLines([...shipRecord.nextActions, `Re-run ship stage and inspect ship lead output.`]), + checklist: [ + ...shipRecord.checklist, + { + item: "Ship lead command", + status: "blocked", + notes: `Command exit code ${shipResult.code}.` + } + ], + reportMarkdown: `${shipRecord.reportMarkdown.trimEnd()}\n\n## Ship lead command\n\nStatus: blocked\n` + }; + } + readinessPolicyRecord = buildReadinessPolicyRecord({ + deliveryMode: options.deliveryMode, + reviewVerdict, + shipRecord, + githubDeliveryRecord, + deploymentEvidenceRecord + }); + } catch (error) { + shipRecord = createFailedShipRecord(summarizeFailureStageError(error), "Ship lead command"); + githubMutation = createBlockedGitHubMutationRecord(); + githubMutation.summary = `Ship stage failed: ${shipRecord.summary}`; + githubMutation.branch = { + initial: "", + current: "", + created: false, + pushed: false, + remote: null }; - } else { - shipRecord = { - ...shipRecord, - checklist: [ - ...shipRecord.checklist, - { - item: "GitHub delivery policy", - status: "complete", - notes: githubDeliveryRecord.overall.summary - } - ], - reportMarkdown: `${shipRecord.reportMarkdown.trimEnd()}\n\n## GitHub delivery\n\nStatus: ready\n` + githubMutation.pullRequest = { + created: false, + updated: false + }; + githubMutation.blockers = [shipRecord.summary]; + githubMutationResult = { + branch: githubMutation.branch.current, + record: githubMutation }; + githubDeliveryRecord = createBlockedGitHubDeliveryRecord({ + gitBranch: options.gitBranch, + deliveryMode: options.deliveryMode, + issueNumbers: options.issueNumbers, + config: options.config, + githubMutationRecord: githubMutation + }); + deploymentEvidenceRecord = createBlockedDeploymentEvidenceRecord( + options.deliveryMode, + "Deployment evidence was not collected because the ship stage failed before GitHub delivery evidence could be finalized." + ); + readinessPolicyRecord = createBlockedReadinessPolicyRecord({ + deliveryMode: options.deliveryMode, + shipRecord, + githubDeliveryRecord, + summary: "Readiness policy evaluation was blocked because the ship stage failed." + }); + await events.emit("failed", `Ship stage failed. ${shipRecord.summary}`); } - await fs.writeFile(path.join(shipStageDir, "artifacts", "ship-summary.md"), shipRecord.reportMarkdown, "utf8"); - await fs.writeFile(path.join(shipStageDir, "artifacts", "release-checklist.md"), renderChecklistMarkdown(shipRecord), "utf8"); - await fs.writeFile(path.join(shipStageDir, "artifacts", "unresolved.md"), renderUnresolvedMarkdown(shipRecord), "utf8"); - await writeJson(path.join(shipStageDir, "artifacts", "ship-record.json"), shipRecord); - await writeJson(path.join(shipStageDir, "artifacts", "github-state.json"), githubEvidence.artifacts.githubState); - await writeJson(path.join(shipStageDir, "artifacts", "pull-request.json"), githubEvidence.artifacts.pullRequest); - await writeJson(path.join(shipStageDir, "artifacts", "issues.json"), githubEvidence.artifacts.issues); - await writeJson(path.join(shipStageDir, "artifacts", "checks.json"), githubEvidence.artifacts.checks); - await writeJson(path.join(shipStageDir, "artifacts", "actions.json"), githubEvidence.artifacts.actions); - await writeJson(path.join(shipStageDir, "artifacts", "security.json"), githubEvidence.artifacts.security); - await writeJson(path.join(shipStageDir, "artifacts", "release.json"), githubEvidence.artifacts.release); - await writeJson(path.join(shipStageDir, "artifacts", "github-mutation.json"), githubMutation.record); - await writeJson(path.join(options.paths.runDir, "artifacts", "github-mutation.json"), githubMutation.record); + + const postShipArtifacts = buildPostShipArtifacts({ + runId: options.runId, + workflow: "deliver", + shipRecord, + githubDeliveryRecord + }); + + await writeShipArtifacts({ + shipStageDir, + shipRecord, + githubDeliveryRecord, + readinessPolicyRecord, + deploymentEvidenceRecord + }); await writeJson(path.join(options.paths.runDir, "artifacts", "github-delivery.json"), githubDeliveryRecord); + await writeJson(path.join(options.paths.runDir, "artifacts", "github-mutation.json"), githubMutationResult.record); + await writeJson(path.join(options.paths.runDir, "artifacts", "readiness-policy.json"), readinessPolicyRecord); + await writeJson(path.join(options.paths.runDir, "artifacts", "deployment-evidence.json"), deploymentEvidenceRecord); + await writePostShipArtifacts({ + artifactsDir: path.join(shipStageDir, "artifacts"), + summaryMarkdown: postShipArtifacts.summaryMarkdown, + evidenceRecord: postShipArtifacts.evidenceRecord, + followUpDraftMarkdown: postShipArtifacts.followUpDraftMarkdown, + followUpRecord: postShipArtifacts.followUpRecord + }); + await writePostShipArtifacts({ + artifactsDir: path.join(options.paths.runDir, "artifacts"), + summaryMarkdown: postShipArtifacts.summaryMarkdown, + evidenceRecord: postShipArtifacts.evidenceRecord, + followUpDraftMarkdown: postShipArtifacts.followUpDraftMarkdown, + followUpRecord: postShipArtifacts.followUpRecord + }); shipStage.status = - shipResult.code === 0 && githubMutation.record.blockers.length === 0 && githubDeliveryRecord.overall.status === "ready" + shipRecord.readiness === "ready" && githubMutationResult.record.blockers.length === 0 && githubDeliveryRecord.overall.status === "ready" ? "completed" : "failed"; shipStage.executed = true; - shipStage.stageDir = shipStageDir; - shipStage.artifactPath = path.join(shipStageDir, "artifacts", "ship-summary.md"); shipStage.notes = shipRecord.summary; - await writeJson(options.paths.stageLineagePath, stageLineage); + await persistLineage(); events.markStage("ship", shipStage.status === "completed" ? "completed" : "failed"); - await events.emit("completed", "Deliver workflow completed"); - events.close(); const finalBody = buildDeliverFinalSummary({ input: options.input, @@ -999,8 +1532,9 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro validationExecution, reviewVerdict, shipRecord, - githubMutationRecord: githubMutation.record, + githubMutationRecord: githubMutationResult.record, githubDeliveryRecord, + readinessPolicyRecord, ...(options.linkedContext ? { linkedContext: options.linkedContext } : {}) }); await fs.writeFile(options.paths.finalPath, finalBody, "utf8"); @@ -1012,11 +1546,14 @@ export async function runDeliverExecution(options: DeliverExecutionOptions): Pro reviewVerdict, shipRecord, githubDeliveryRecord, - githubMutationRecord: githubMutation.record, + githubMutationRecord: githubMutationResult.record, stageLineage, selectedSpecialists, finalBody }; + } finally { + events.close(); + } } export { resolveLinkedBuildContext }; diff --git a/src/discover.ts b/src/discover.ts index 4845224..dc4268c 100644 --- a/src/discover.ts +++ b/src/discover.ts @@ -4,6 +4,7 @@ import { buildEvent, ProgressReporter } from "./progress.js"; import { runCodexExec, type CodexRunResult } from "./codex.js"; import { buildDiscoverLeadPrompt, buildDiscoverTrackPrompt } from "./prompt.js"; import type { + CapabilityUsageRecord, CstackConfig, DiscoverDelegateResult, DiscoverResearchPlan, @@ -14,6 +15,8 @@ import type { } from "./types.js"; const TRACK_ORDER: DiscoverTrackName[] = ["repo-explorer", "risk-researcher", "external-researcher"]; +const DISCOVER_LEAD_RESERVE_SECONDS = 15; +const DISCOVER_STALE_SESSION_TIMEOUT_SECONDS = 60; interface DiscoverPaths { runDir: string; @@ -32,6 +35,7 @@ export interface DiscoverExecutionOptions { runId: string; input: string; config: CstackConfig; + planningIssueNumber?: number; paths: DiscoverPaths; } @@ -40,6 +44,8 @@ export interface DiscoverExecutionResult { delegates: DiscoverDelegateResult[]; leadResult: CodexRunResult; finalBody: string; + status: "completed" | "partial" | "failed"; + notes: string[]; } interface DiscoverLeadJson { @@ -60,7 +66,60 @@ function compact(input: string): string { return input.replace(/\s+/g, " ").trim(); } -function inferTrackSelections(input: string, config: CstackConfig): DiscoverResearchPlan { +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function buildDiscoverCapabilityRecord(options: { + config: CstackConfig; + requested: string[]; + selectedTracks: DiscoverTrackSelection[]; + webResearchAllowed: boolean; +}): CapabilityUsageRecord { + const workflowPolicy = options.config.workflows.discover.capabilities ?? {}; + const allowed = unique(workflowPolicy.allowed ?? []); + const requested = unique([...(workflowPolicy.defaultRequested ?? []), ...options.requested]); + const downgraded: CapabilityUsageRecord["downgraded"] = []; + const available: string[] = []; + + for (const capability of requested) { + if (allowed.length > 0 && !allowed.includes(capability)) { + downgraded.push({ + name: capability, + reason: "not allowed by workflow capability policy" + }); + continue; + } + if (capability === "web" && !options.webResearchAllowed) { + downgraded.push({ + name: capability, + reason: "disabled by discover research policy" + }); + continue; + } + available.push(capability); + } + + const used = unique([ + "shell", + ...(options.selectedTracks.some((track) => track.name === "external-researcher" && track.selected) && available.includes("web") ? ["web"] : []) + ]).filter((capability) => available.includes(capability)); + + return { + workflow: "discover", + allowed, + requested, + available, + used, + downgraded, + notes: [ + `web research allowed: ${options.webResearchAllowed ? "yes" : "no"}`, + `selected tracks: ${options.selectedTracks.filter((track) => track.selected).map((track) => track.name).join(", ") || "none"}` + ] + }; +} + +function inferTrackSelections(input: string, config: CstackConfig, planningIssueNumber?: number): DiscoverResearchPlan { const lower = input.toLowerCase(); const words = input.trim().split(/\s+/).filter(Boolean).length; const researchEnabled = config.workflows.discover.research?.enabled !== false; @@ -93,6 +152,11 @@ function inferTrackSelections(input: string, config: CstackConfig): DiscoverRese } } + const repoOnlyDelegation = selected.size === 1 && selected.has("repo-explorer"); + if (repoOnlyDelegation) { + selected.clear(); + } + const tracks: DiscoverTrackSelection[] = TRACK_ORDER.map((track) => { switch (track) { case "repo-explorer": @@ -128,7 +192,7 @@ function inferTrackSelections(input: string, config: CstackConfig): DiscoverRese }); const requestedCapabilities = ["shell"]; - if (selected.has("external-researcher")) { + if (selected.has("external-researcher") || externalSignal) { requestedCapabilities.push("web"); } @@ -136,6 +200,9 @@ function inferTrackSelections(input: string, config: CstackConfig): DiscoverRese if (!shouldDelegate) { limitations.push("Delegated research was suppressed because the prompt appears small, local, or not broad enough to justify fan-out."); } + if (repoOnlyDelegation) { + limitations.push("Delegated research was suppressed because only the repo-explorer track qualified; a single-agent discover pass is faster and more reliable for repo-only prompts."); + } if (externalSignal && !allowWeb) { limitations.push("Web-backed external research was requested by the prompt signal but disabled by discover policy."); } @@ -144,6 +211,7 @@ function inferTrackSelections(input: string, config: CstackConfig): DiscoverRese prompt: input, decidedAt: new Date().toISOString(), mode: shouldDelegate && selected.size > 0 ? "research-team" : "single-agent", + ...(typeof planningIssueNumber === "number" ? { planningIssueNumber } : {}), delegationEnabled, maxTracks, webResearchAllowed: allowWeb, @@ -229,6 +297,33 @@ function urlsFromText(input: string): DiscoverSourceRecord[] { })); } +function hasMeaningfulDelegateEvidence(delegate: Pick<DiscoverDelegateResult, "findings" | "sources" | "filesInspected" | "commandsRun" | "unresolved">): boolean { + return ( + delegate.findings.length > 0 || + delegate.sources.length > 0 || + delegate.filesInspected.length > 0 || + delegate.commandsRun.length > 0 || + delegate.unresolved.length > 0 + ); +} + +function hasUsableLeadArtifact(lead: DiscoverLeadJson): boolean { + return ( + (lead.localFindings?.length ?? 0) > 0 || + (lead.externalFindings?.length ?? 0) > 0 || + (lead.risks?.length ?? 0) > 0 || + (lead.openQuestions?.length ?? 0) > 0 + ); +} + +function remainingBudgetSeconds(startedAt: number, totalSeconds: number | undefined, reserveSeconds = 0): number | undefined { + if (typeof totalSeconds !== "number" || totalSeconds <= 0) { + return undefined; + } + const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1000); + return Math.max(0, totalSeconds - elapsedSeconds - reserveSeconds); +} + function normalizeDelegateResult( track: DiscoverTrackName, rawText: string, @@ -236,7 +331,7 @@ function normalizeDelegateResult( sessionId?: string ): Omit<DiscoverDelegateResult, "delegateDir" | "artifactPath" | "resultPath" | "sourcesPath"> { const parsed = parseJson<Record<string, unknown>>(rawText); - const summary = typeof parsed?.summary === "string" ? compact(parsed.summary) : compact(rawText.split("\n")[0] ?? ""); + const summary = typeof parsed?.summary === "string" ? compact(parsed.summary) : ""; const status = parsed?.status; const findings = Array.isArray(parsed?.findings) ? parsed!.findings.filter((value): value is string => typeof value === "string").map(compact) : []; const unresolved = Array.isArray(parsed?.unresolved) @@ -252,6 +347,8 @@ function normalizeDelegateResult( ? parsed!.sources.map(normalizeSource).filter((value): value is DiscoverSourceRecord => value !== null) : []; const confidence = parsed?.confidence; + const completedWithStructuredEvidence = + status === "completed" && (findings.length > 0 || unresolved.length > 0 || filesInspected.length > 0 || commandsRun.length > 0 || rawSources.length > 0); return { track, @@ -261,15 +358,15 @@ function normalizeDelegateResult( : code === 0 ? "completed" : "failed", - summary: summary || `${track} completed without a structured summary.`, + summary: summary || (completedWithStructuredEvidence ? `${track} returned structured findings.` : `${track} did not produce structured findings.`), filesInspected, commandsRun, - sources: rawSources.length > 0 ? rawSources : urlsFromText(rawText), - findings: findings.length > 0 ? findings : [summary || `${track} completed.`], + sources: rawSources.length > 0 ? rawSources : code === 0 ? urlsFromText(rawText) : [], + findings: findings, confidence: confidence === "low" || confidence === "medium" || confidence === "high" ? confidence : "medium", unresolved, - leaderDisposition: code === 0 ? "accepted" : "discarded", - ...(code !== 0 ? { notes: `${track} exited unsuccessfully and was discarded by default.` } : {}), + leaderDisposition: completedWithStructuredEvidence ? "partial" : code === 0 ? "accepted" : "discarded", + ...(code !== 0 ? { notes: completedWithStructuredEvidence ? `${track} exited non-zero after producing structured findings.` : `${track} exited unsuccessfully before producing structured findings.` } : {}), ...(sessionId ? { sessionId } : {}) }; } @@ -328,7 +425,21 @@ function synthesizeFallbackReport(input: string, delegates: DiscoverDelegateResu function normalizeLeadJson(input: string, delegates: DiscoverDelegateResult[], plan: DiscoverResearchPlan, rawText: string): DiscoverLeadJson { const parsed = parseJson<DiscoverLeadJson>(rawText); if (!parsed) { - return synthesizeFallbackReport(input, delegates, plan); + return delegates.some((delegate) => hasMeaningfulDelegateEvidence(delegate)) + ? synthesizeFallbackReport(input, delegates, plan) + : { + summary: plan.summary, + localFindings: [], + externalFindings: [], + risks: [], + openQuestions: [], + delegateDisposition: delegates.map((delegate) => ({ + track: delegate.track, + leaderDisposition: delegate.leaderDisposition, + reason: delegate.summary + })), + reportMarkdown: "" + }; } return { summary: typeof parsed.summary === "string" ? compact(parsed.summary) : plan.summary, @@ -424,6 +535,7 @@ async function runTrack(options: { plan: DiscoverResearchPlan; track: DiscoverTrackSelection; recorder: ReturnType<typeof createDiscoverRecorder>; + timeoutSeconds?: number; }): Promise<{ delegate: DiscoverDelegateResult; result: CodexRunResult }> { const delegateDir = path.join(options.stageDir, "delegates", options.track.name); await fs.mkdir(delegateDir, { recursive: true }); @@ -462,7 +574,9 @@ async function runTrack(options: { stdoutPath, stderrPath, config: options.config, - silentProgress: true + silentProgress: true, + staleSessionTimeoutSeconds: DISCOVER_STALE_SESSION_TIMEOUT_SECONDS, + ...(typeof options.timeoutSeconds === "number" ? { timeoutSeconds: options.timeoutSeconds } : {}) }); const rawFinal = await readBestEffortCodexOutput({ finalPath, stdoutPath, stderrPath }); @@ -477,28 +591,70 @@ async function runTrack(options: { await writeJson(resultPath, finalized); await writeJson(sourcesPath, finalized.sources); - options.recorder.markTrack(options.track.name, result.code === 0 ? "completed" : "failed"); + options.recorder.markTrack( + options.track.name, + finalized.leaderDisposition === "discarded" ? "failed" : "completed" + ); return { delegate: finalized, result }; } export async function runDiscoverExecution(options: DiscoverExecutionOptions): Promise<DiscoverExecutionResult> { - const { cwd, runId, input, config, paths } = options; + const { cwd, runId, input, config, planningIssueNumber, paths } = options; await fs.mkdir(path.join(paths.stageDir, "artifacts"), { recursive: true }); await fs.mkdir(path.join(paths.stageDir, "delegates"), { recursive: true }); await fs.writeFile(paths.eventsPath, "", "utf8"); - const plan = inferTrackSelections(input, config); + const plan = inferTrackSelections(input, config, planningIssueNumber); + const capabilityRecord = buildDiscoverCapabilityRecord({ + config, + requested: plan.requestedCapabilities, + selectedTracks: plan.tracks, + webResearchAllowed: plan.webResearchAllowed + }); + plan.requestedCapabilities = capabilityRecord.requested; + plan.availableCapabilities = capabilityRecord.available; const researchPlanPath = path.join(paths.stageDir, "research-plan.json"); + const capabilityArtifactPath = path.join(paths.runDir, "artifacts", "capabilities.json"); const discoveryReportPath = path.join(paths.stageDir, "artifacts", "discovery-report.md"); await writeJson(researchPlanPath, plan); + await writeJson(capabilityArtifactPath, capabilityRecord); const selectedTracks = plan.tracks.filter((track) => track.selected); const recorder = createDiscoverRecorder(runId, paths.eventsPath); + const startedAt = Date.now(); + const discoverTimeoutSeconds = config.workflows.discover.timeoutSeconds; + const leadReserveSeconds = + typeof discoverTimeoutSeconds === "number" && discoverTimeoutSeconds > 0 + ? Math.min(DISCOVER_LEAD_RESERVE_SECONDS, Math.max(1, Math.floor(discoverTimeoutSeconds / 3))) + : DISCOVER_LEAD_RESERVE_SECONDS; + const notes: string[] = []; recorder.setTracks(selectedTracks.map((track) => track.name)); await recorder.emit("starting", `Discover research plan: ${plan.summary}`); const delegates: DiscoverDelegateResult[] = []; for (const track of selectedTracks) { + const trackBudget = remainingBudgetSeconds(startedAt, discoverTimeoutSeconds, leadReserveSeconds); + if (typeof trackBudget === "number" && trackBudget <= 0) { + const skippedDelegate: DiscoverDelegateResult = { + track: track.name, + status: "stalled", + summary: `${track.name} was skipped because the shared discover budget was exhausted before the track started.`, + filesInspected: [], + commandsRun: [], + sources: [], + findings: [], + confidence: "low", + unresolved: [], + leaderDisposition: "discarded", + notes: "Shared discover budget exhausted before this delegated track began." + }; + delegates.push(skippedDelegate); + recorder.markTrack(track.name, "failed"); + await recorder.emit("failed", skippedDelegate.summary); + notes.push(skippedDelegate.summary); + continue; + } + const { delegate } = await runTrack({ cwd, runId, @@ -507,9 +663,13 @@ export async function runDiscoverExecution(options: DiscoverExecutionOptions): P stageDir: paths.stageDir, plan, track, - recorder + recorder, + ...(typeof trackBudget === "number" ? { timeoutSeconds: trackBudget } : {}) }); delegates.push(delegate); + if (delegate.notes) { + notes.push(delegate.notes); + } } const { prompt, context } = await buildDiscoverLeadPrompt({ @@ -522,21 +682,39 @@ export async function runDiscoverExecution(options: DiscoverExecutionOptions): P await fs.writeFile(paths.contextPath, `${context}\n`, "utf8"); await recorder.emit("activity", "Running Research Lead synthesis"); - const leadResult = await runCodexExec({ - cwd, - workflow: "discover", - runId: `${runId}-research-lead`, - prompt, - finalPath: paths.finalPath, - eventsPath: path.join(paths.stageDir, "lead-events.jsonl"), - stdoutPath: paths.stdoutPath, - stderrPath: paths.stderrPath, - config, - silentProgress: true - }); + const leadBudget = remainingBudgetSeconds(startedAt, discoverTimeoutSeconds); + const leadResult: CodexRunResult = + typeof leadBudget === "number" && leadBudget <= 0 + ? { + code: 124, + signal: null, + command: [], + timedOut: true, + ...(typeof discoverTimeoutSeconds === "number" ? { timeoutSeconds: discoverTimeoutSeconds } : {}), + lastActivity: "Shared discover budget was exhausted before Research Lead synthesis started." + } + : await runCodexExec({ + cwd, + workflow: "discover", + runId: `${runId}-research-lead`, + prompt, + finalPath: paths.finalPath, + eventsPath: path.join(paths.stageDir, "lead-events.jsonl"), + stdoutPath: paths.stdoutPath, + stderrPath: paths.stderrPath, + config, + silentProgress: true, + staleSessionTimeoutSeconds: DISCOVER_STALE_SESSION_TIMEOUT_SECONDS, + ...(typeof leadBudget === "number" ? { timeoutSeconds: leadBudget } : {}) + }); if (leadResult.sessionId) { await recorder.emit("session", leadResult.sessionId); } + if (leadResult.stallReason) { + notes.push(leadResult.stallReason); + } else if (leadResult.lastActivity && leadResult.code !== 0) { + notes.push(leadResult.lastActivity); + } const rawLead = await readBestEffortCodexOutput({ finalPath: paths.finalPath, @@ -545,6 +723,26 @@ export async function runDiscoverExecution(options: DiscoverExecutionOptions): P }); const leadJson = normalizeLeadJson(input, delegates, plan, rawLead); const finalizedDelegates = applyLeadDisposition(delegates, leadJson); + const usableArtifact = hasUsableLeadArtifact(leadJson); + const finalBody = + usableArtifact && leadJson.reportMarkdown && leadJson.reportMarkdown.trim() + ? leadJson.reportMarkdown + : [ + "# Discovery Report", + "", + "## Request", + input, + "", + "## Status", + usableArtifact ? "partial" : "failed", + "", + "## Findings", + "- none recovered", + "", + "## Blockers", + ...(notes.length > 0 ? notes.map((note) => `- ${note}`) : ["- Discover did not recover usable findings before the stage ended."]) + ].join("\n") + "\n"; + const status: DiscoverExecutionResult["status"] = usableArtifact ? (leadResult.code === 0 ? "completed" : "partial") : "failed"; for (const delegate of finalizedDelegates) { if (delegate.resultPath) { @@ -555,12 +753,12 @@ export async function runDiscoverExecution(options: DiscoverExecutionOptions): P } } - await fs.writeFile(paths.finalPath, leadJson.reportMarkdown ?? rawLead, "utf8"); - await fs.writeFile(paths.artifactPath, leadJson.reportMarkdown ?? rawLead, "utf8"); - await fs.writeFile(discoveryReportPath, leadJson.reportMarkdown ?? rawLead, "utf8"); + await fs.writeFile(paths.finalPath, finalBody, "utf8"); + await fs.writeFile(paths.artifactPath, finalBody, "utf8"); + await fs.writeFile(discoveryReportPath, finalBody, "utf8"); await recorder.emit( - leadResult.code === 0 ? "completed" : "failed", - leadResult.code === 0 ? "Discover run completed" : `Discover run failed with code ${leadResult.code}` + status === "failed" ? "failed" : "completed", + status === "completed" ? "Discover run completed" : status === "partial" ? `Discover run completed with recovered partial output (exit ${leadResult.code})` : `Discover run failed with code ${leadResult.code}` ); recorder.close(); @@ -568,6 +766,8 @@ export async function runDiscoverExecution(options: DiscoverExecutionOptions): P researchPlan: plan, delegates: finalizedDelegates, leadResult, - finalBody: leadJson.reportMarkdown ?? rawLead + finalBody, + status, + notes }; } diff --git a/src/inspector.ts b/src/inspector.ts index ac6413d..4d73583 100644 --- a/src/inspector.ts +++ b/src/inspector.ts @@ -7,16 +7,23 @@ import type { ChildRunInspection, BuildSessionRecord, BuildVerificationRecord, + CapabilityUsageRecord, + DeliveryReadinessPolicyRecord, + DeploymentEvidenceRecord, DeliverValidationLocalRecord, DeliverValidationPlan, DeliverReviewVerdict, DeliverShipRecord, DiscoverDelegateResult, DiscoverResearchPlan, + InitiativeGraphRecord, ExecutionContextRecord, GitHubGateEvaluation, GitHubDeliveryRecord, GitHubMutationRecord, + PostShipEvidenceRecord, + PostShipFollowUpRecord, + PlanningIssueLineageRecord, RoutingPlan, RunEvent, RunInspection, @@ -95,6 +102,53 @@ async function tailTextFile(filePath?: string, maxLines = 20): Promise<string> { .join("\n"); } +function formatInitiativeLabel(initiativeId?: string, initiativeTitle?: string): string { + if (initiativeId && initiativeTitle) { + return `${initiativeId} (${initiativeTitle})`; + } + return initiativeId ?? initiativeTitle ?? "-"; +} + +async function deriveInitiativeGraph(cwd: string, run: RunInspection["run"]): Promise<InitiativeGraphRecord | null> { + const initiativeId = run.inputs.initiativeId; + if (!initiativeId) { + return null; + } + + const initiativeRuns = (await listRuns(cwd)) + .filter((entry) => entry.inputs?.initiativeId === initiativeId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + + const sourceRun = initiativeRuns[0]; + const relatedRuns = initiativeRuns + .filter((entry) => entry.id !== run.id) + .map((entry) => ({ + runId: entry.id, + workflow: entry.workflow, + status: entry.status + })); + + return { + initiativeId, + ...(run.inputs.initiativeTitle ? { initiativeTitle: run.inputs.initiativeTitle } : {}), + ...(sourceRun + ? { + sourceRun: { + runId: sourceRun.id, + workflow: sourceRun.workflow, + ...(sourceRun.summary ? { summary: sourceRun.summary } : {}) + } + } + : {}), + currentRun: { + runId: run.id, + workflow: run.workflow, + ...(run.summary ? { summary: run.summary } : {}) + }, + relatedRuns + }; +} + function childBuildStageDir(child: ChildRunInspection): string | null { if (child.run.workflow === "deliver") { return path.join(child.runDir, "stages", "build"); @@ -495,6 +549,12 @@ function renderSuggestedActions(inspection: RunInspection): string[] { lines.push("- inspect CI validation with `show ci-validation`"); lines.push("- inspect the review verdict with `show review`"); lines.push("- inspect ship readiness with `show ship`"); + if (inspection.postShipEvidenceRecord) { + lines.push("- inspect post-ship evidence with `show post-ship`"); + } + if (inspection.postShipFollowUpRecord) { + lines.push("- inspect follow-up lineage with `show follow-up`"); + } if (inspection.githubMutationRecord) { lines.push("- inspect GitHub mutation state with `show mutation`"); } @@ -520,6 +580,12 @@ function renderSuggestedActions(inspection: RunInspection): string[] { } if (inspection.run.workflow === "ship") { lines.push("- inspect ship readiness with `show ship`"); + if (inspection.postShipEvidenceRecord) { + lines.push("- inspect post-ship evidence with `show post-ship`"); + } + if (inspection.postShipFollowUpRecord) { + lines.push("- inspect follow-up lineage with `show follow-up`"); + } if (inspection.githubMutationRecord) { lines.push("- inspect GitHub mutation state with `show mutation`"); } @@ -896,11 +962,21 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru const deliverValidationLocalPath = path.join(runDir, "stages", "validation", "artifacts", "local-validation.json"); const deliverReviewVerdictPath = path.join(runDir, "stages", "review", "artifacts", "verdict.json"); const deliverShipRecordPath = path.join(runDir, "stages", "ship", "artifacts", "ship-record.json"); + const deliverReadinessPolicyPath = path.join(runDir, "stages", "ship", "artifacts", "readiness-policy.json"); + const deliverDeploymentEvidencePath = path.join(runDir, "stages", "ship", "artifacts", "deployment-evidence.json"); const reviewVerdictPath = path.join(runDir, "artifacts", "verdict.json"); const shipRecordPath = path.join(runDir, "artifacts", "ship-record.json"); + const readinessPolicyPath = path.join(runDir, "artifacts", "readiness-policy.json"); + const deploymentEvidencePath = path.join(runDir, "artifacts", "deployment-evidence.json"); const executionContextPath = path.join(runDir, "execution-context.json"); const githubDeliveryPath = path.join(runDir, "artifacts", "github-delivery.json"); const githubMutationPath = path.join(runDir, "artifacts", "github-mutation.json"); + const postShipEvidencePath = path.join(runDir, "artifacts", "post-ship-evidence.json"); + const postShipFollowUpPath = path.join(runDir, "artifacts", "follow-up-lineage.json"); + const planningIssueLineagePath = path.join(runDir, "artifacts", "issue-lineage.json"); + const initiativeGraphPath = path.join(runDir, "artifacts", "initiative-graph.json"); + const discoverCapabilitiesPath = path.join(runDir, "artifacts", "capabilities.json"); + const validationCapabilitiesPath = path.join(runDir, "stages", "validation", "artifacts", "capabilities.json"); const buildFailureDiagnosisPath = run.workflow === "deliver" ? path.join(runDir, "stages", "build", "artifacts", "failure-diagnosis.json") @@ -911,6 +987,9 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru recentEvents, routingPlan, stageLineage, + planningIssueLineage, + discoverCapabilitiesRecord, + validationCapabilitiesRecord, discoverResearchPlan, discoverDelegates, sessionRecord, @@ -921,8 +1000,13 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru validationLocalRecord, deliverReviewVerdict, deliverShipRecord, + readinessPolicyRecord, + deploymentEvidenceRecord, githubDeliveryRecord, githubMutationRecord, + postShipEvidenceRecord, + postShipFollowUpRecord, + initiativeGraphRecord, executionContext, buildFailureDiagnosis, artifacts @@ -930,6 +1014,9 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru readRecentEvents(run.eventsPath), readJsonFile<RoutingPlan>(path.join(runDir, "routing-plan.json")), readJsonFile<StageLineage>(path.join(runDir, "stage-lineage.json")), + readJsonFile<PlanningIssueLineageRecord>(planningIssueLineagePath), + readJsonFile<CapabilityUsageRecord>(discoverCapabilitiesPath), + readJsonFile<CapabilityUsageRecord>(validationCapabilitiesPath), readJsonFile<DiscoverResearchPlan>(path.join(runDir, "stages", "discover", "research-plan.json")), loadDiscoverDelegates(runDir), readJsonFile<BuildSessionRecord>(run.workflow === "deliver" ? deliverBuildSessionPath : run.workflow === "build" ? sessionPath : ""), @@ -940,8 +1027,17 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru readJsonFile<DeliverValidationLocalRecord>(deliverValidationLocalPath), readJsonFile<DeliverReviewVerdict>(run.workflow === "deliver" ? deliverReviewVerdictPath : run.workflow === "review" ? reviewVerdictPath : ""), readJsonFile<DeliverShipRecord>(run.workflow === "deliver" ? deliverShipRecordPath : run.workflow === "ship" ? shipRecordPath : ""), + readJsonFile<DeliveryReadinessPolicyRecord>( + run.workflow === "deliver" ? deliverReadinessPolicyPath : run.workflow === "ship" ? readinessPolicyPath : "" + ), + readJsonFile<DeploymentEvidenceRecord>( + run.workflow === "deliver" ? deliverDeploymentEvidencePath : run.workflow === "ship" ? deploymentEvidencePath : "" + ), readJsonFile<GitHubDeliveryRecord>(githubDeliveryPath), readJsonFile<GitHubMutationRecord>(githubMutationPath), + readJsonFile<PostShipEvidenceRecord>(postShipEvidencePath), + readJsonFile<PostShipFollowUpRecord>(postShipFollowUpPath), + readJsonFile<InitiativeGraphRecord>(initiativeGraphPath), readJsonFile<ExecutionContextRecord>(executionContextPath), readJsonFile<BuildFailureDiagnosisRecord>(buildFailureDiagnosisPath), walkArtifacts(runDir) @@ -960,6 +1056,9 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru runDir, routingPlan, stageLineage, + planningIssueLineage, + discoverCapabilitiesRecord, + validationCapabilitiesRecord, discoverResearchPlan, discoverDelegates, sessionRecord, @@ -970,9 +1069,14 @@ export async function loadRunInspection(cwd: string, runId?: string): Promise<Ru validationLocalRecord, deliverReviewVerdict, deliverShipRecord, + readinessPolicyRecord, + deploymentEvidenceRecord, githubDeliveryRecord, githubMutationRecord, + postShipEvidenceRecord, + postShipFollowUpRecord, executionContext, + initiativeGraph: initiativeGraphRecord ?? (await deriveInitiativeGraph(cwd, run)), recentEvents, finalBody, buildFinalBody, @@ -997,6 +1101,8 @@ function renderResearchSection(inspection: RunInspection): string[] { "Research", `- mode: ${discoverResearchPlan.mode}`, `- web research allowed: ${discoverResearchPlan.webResearchAllowed ? "yes" : "no"}`, + `- requested capabilities: ${discoverResearchPlan.requestedCapabilities.join(", ") || "none"}`, + `- available capabilities: ${discoverResearchPlan.availableCapabilities.join(", ") || "none"}`, `- selected tracks: ${ discoverResearchPlan.tracks.filter((track) => track.selected).map((track) => track.name).join(", ") || "none" }`, @@ -1116,16 +1222,39 @@ export function renderInspectionSummary(cwd: string, inspection: RunInspection): : undefined, inspection.sessionRecord ? `- mode: requested ${inspection.sessionRecord.requestedMode}, observed ${inspection.sessionRecord.mode}` : undefined, inspection.sessionRecord?.linkedRunId ? `- linked run: ${inspection.sessionRecord.linkedRunId}` : undefined, + run.inputs.planningIssueNumber ? `- planning issue: #${run.inputs.planningIssueNumber}` : undefined, + inspection.artifacts.some((artifact) => artifact.path === "artifacts/issue-draft.md") ? "- issue draft: artifacts/issue-draft.md" : undefined, + inspection.planningIssueLineage ? "- issue lineage: artifacts/issue-lineage.json" : undefined, + run.inputs.initiativeId || run.inputs.initiativeTitle + ? `- initiative: ${formatInitiativeLabel(run.inputs.initiativeId, run.inputs.initiativeTitle)}` + : undefined, + inspection.initiativeGraph + ? `- initiative runs: ${inspection.initiativeGraph.relatedRuns.length + 1}` + : undefined, + inspection.artifacts.some((artifact) => artifact.path === "artifacts/initiative-graph.json") + ? "- initiative graph: artifacts/initiative-graph.json" + : inspection.initiativeGraph + ? "- initiative graph: derived from run metadata" + : undefined, + inspection.discoverCapabilitiesRecord + ? `- capabilities: discover requested=${inspection.discoverCapabilitiesRecord.requested.join(", ") || "none"}` + : inspection.validationCapabilitiesRecord + ? `- capabilities: validation requested=${inspection.validationCapabilitiesRecord.requested.join(", ") || "none"}` + : undefined, inspection.verificationRecord ? `- verification: ${renderVerificationSummary(inspection.verificationRecord)}` : undefined, - inspection.validationPlan ? `- validation: ${inspection.validationPlan.status}` : undefined, + inspection.validationPlan ? `- validation: ${inspection.validationPlan.status} (${inspection.validationPlan.outcomeCategory})` : undefined, inspection.validationLocalRecord ? `- local validation: ${inspection.validationLocalRecord.status}` : undefined, directBuildFailure ? `- root cause: build failed: ${directBuildFailure}` : undefined, ...(directBuildFailure ? renderInspectionBuildFailureEvidence(inspection, cwd) : []), renderReviewHeadline(inspection.deliverReviewVerdict), ...reviewDetails, - inspection.deliverShipRecord ? `- ship readiness: ${inspection.deliverShipRecord.readiness}` : undefined, - inspection.githubMutationRecord ? `- github mutation: ${inspection.githubMutationRecord.summary}` : undefined, - inspection.githubDeliveryRecord ? `- github delivery: ${renderGitHubSummary(inspection.githubDeliveryRecord)}` : undefined, + inspection.deliverShipRecord ? `- ship readiness: ${inspection.deliverShipRecord.readiness}` : undefined, + inspection.readinessPolicyRecord ? `- readiness policy: ${inspection.readinessPolicyRecord.summary}` : undefined, + inspection.deploymentEvidenceRecord ? `- deployment evidence: ${inspection.deploymentEvidenceRecord.summary}` : undefined, + inspection.postShipEvidenceRecord ? `- post-ship: ${inspection.postShipEvidenceRecord.status}` : undefined, + inspection.postShipFollowUpRecord ? `- post-ship follow-up: ${inspection.postShipFollowUpRecord.status}` : undefined, + inspection.githubMutationRecord ? `- github mutation: ${inspection.githubMutationRecord.summary}` : undefined, + inspection.githubDeliveryRecord ? `- github delivery: ${renderGitHubSummary(inspection.githubDeliveryRecord)}` : undefined, ...failedChildRuns.flatMap((child) => { const rootStage = childRootFailedStage(child); return [ @@ -1202,6 +1331,105 @@ function renderVerification(inspection: RunInspection): string { return `${JSON.stringify(inspection.verificationRecord, null, 2)}\n`; } +function renderCapabilities(inspection: RunInspection): string { + const records = [ + inspection.discoverCapabilitiesRecord + ? { label: "discover", path: "artifacts/capabilities.json", record: inspection.discoverCapabilitiesRecord } + : null, + inspection.validationCapabilitiesRecord + ? { label: "validation", path: "stages/validation/artifacts/capabilities.json", record: inspection.validationCapabilitiesRecord } + : null + ].filter((value): value is { label: string; path: string; record: CapabilityUsageRecord } => value !== null); + + if (records.length === 0) { + return "No capability artifacts were recorded for this run."; + } + + return records + .map(({ label, path, record }) => + [ + `${label} capabilities`, + `- artifact: ${path}`, + `- allowed: ${record.allowed.join(", ") || "none"}`, + `- requested: ${record.requested.join(", ") || "none"}`, + `- available: ${record.available.join(", ") || "none"}`, + `- used: ${record.used.join(", ") || "none"}`, + ...(record.downgraded.length > 0 + ? record.downgraded.map((entry) => `- downgraded: ${entry.name} (${entry.reason})`) + : ["- downgraded: none"]), + ...(record.notes?.length ? record.notes.map((note) => `- note: ${note}`) : []) + ].join("\n") + ) + .join("\n\n"); +} + +function renderPlanningIssue(inspection: RunInspection): string { + const draftPath = "artifacts/issue-draft.md"; + const hasDraft = inspection.artifacts.some((artifact) => artifact.path === draftPath); + if (!inspection.run.inputs.planningIssueNumber && !inspection.planningIssueLineage && !hasDraft) { + return "No planning issue artifacts were recorded for this run."; + } + + return [ + `Planning issue: ${inspection.run.inputs.planningIssueNumber ? `#${inspection.run.inputs.planningIssueNumber}` : "not recorded"}`, + inspection.run.inputs.planningIssueUrl ? `Issue URL: ${inspection.run.inputs.planningIssueUrl}` : undefined, + hasDraft ? `Issue draft: ${draftPath}` : "Issue draft: not recorded", + inspection.planningIssueLineage ? "Issue lineage: artifacts/issue-lineage.json" : "Issue lineage: not recorded", + inspection.planningIssueLineage + ? `Current run: ${inspection.planningIssueLineage.currentRun.runId} (${inspection.planningIssueLineage.currentRun.workflow})` + : undefined, + inspection.planningIssueLineage?.sourceRun + ? `Upstream run: ${inspection.planningIssueLineage.sourceRun.runId} (${inspection.planningIssueLineage.sourceRun.workflow ?? "unknown"})` + : undefined, + inspection.planningIssueLineage + ? `Downstream PRs: ${inspection.planningIssueLineage.downstreamPullRequests.length}` + : undefined, + ...(inspection.planningIssueLineage?.downstreamPullRequests.length + ? inspection.planningIssueLineage.downstreamPullRequests.map( + (pr) => `- PR #${pr.number}${pr.state ? ` (${pr.state})` : ""}${pr.url ? ` ${pr.url}` : ""}` + ) + : []), + inspection.planningIssueLineage + ? `Downstream releases: ${inspection.planningIssueLineage.downstreamReleases.length}` + : undefined, + ...(inspection.planningIssueLineage?.downstreamReleases.length + ? inspection.planningIssueLineage.downstreamReleases.map( + (release) => `- release ${release.tag}${release.state ? ` (${release.state})` : ""}${release.url ? ` ${release.url}` : ""}` + ) + : []) + ] + .filter(Boolean) + .join("\n"); +} + +function renderInitiative(inspection: RunInspection): string { + const initiativeArtifactPath = "artifacts/initiative-graph.json"; + const hasArtifact = inspection.artifacts.some((artifact) => artifact.path === initiativeArtifactPath); + if (!inspection.run.inputs.initiativeId && !hasArtifact && !inspection.initiativeGraph) { + return "No initiative artifacts were recorded for this run."; + } + + return [ + `Initiative: ${formatInitiativeLabel(inspection.run.inputs.initiativeId, inspection.run.inputs.initiativeTitle)}`, + hasArtifact ? `Artifact: ${initiativeArtifactPath}` : "Artifact: derived from run metadata", + inspection.initiativeGraph ? `Source run: ${inspection.initiativeGraph.sourceRun?.runId ?? "not recorded"}` : undefined, + inspection.initiativeGraph ? `Related runs: ${inspection.initiativeGraph.relatedRuns.length}` : undefined, + inspection.initiativeGraph + ? `Current run: ${inspection.initiativeGraph.currentRun.runId} (${inspection.initiativeGraph.currentRun.workflow})` + : undefined, + ...(inspection.initiativeGraph && inspection.initiativeGraph.relatedRuns.length > 0 + ? [ + "Run group:", + ...inspection.initiativeGraph.relatedRuns.map( + (related) => `- ${related.runId} (${related.workflow}, ${related.status})` + ) + ] + : []) + ] + .filter(Boolean) + .join("\n"); +} + function renderValidation(inspection: RunInspection): string { if (!inspection.validationPlan) { return "No validation plan was recorded for this run."; @@ -1211,6 +1439,7 @@ function renderValidation(inspection: RunInspection): string { inspection.validationRepoProfile ? [ `Validation status: ${inspection.validationPlan.status}`, + `Outcome category: ${inspection.validationPlan.outcomeCategory}`, `Profile surfaces: ${inspection.validationRepoProfile.surfaces.join(", ") || "unknown"}`, `Workspace targets: ${inspection.validationRepoProfile.workspaceTargets.length}`, ...inspection.validationRepoProfile.workspaceTargets.map( @@ -1236,7 +1465,19 @@ async function renderValidationPyramid(inspection: RunInspection): Promise<strin } async function renderValidationCoverage(inspection: RunInspection): Promise<string> { - return readRelativeArtifact(inspection, "stages/validation/artifacts/coverage-summary.json"); + if (!inspection.validationPlan) { + return "No validation plan was recorded for this run."; + } + const raw = await readRelativeArtifact(inspection, "stages/validation/artifacts/coverage-summary.json"); + if (raw.startsWith("Artifact `")) { + return raw; + } + return [ + `Validation status: ${inspection.validationPlan.status}`, + `Outcome category: ${inspection.validationPlan.outcomeCategory}`, + "", + raw + ].join("\n"); } async function renderValidationCi(inspection: RunInspection): Promise<string> { @@ -1294,6 +1535,105 @@ function renderDeliverShip(inspection: RunInspection): string { return `${JSON.stringify(inspection.deliverShipRecord, null, 2)}\n`; } +function renderReadinessPolicy(inspection: RunInspection): string { + if (!inspection.readinessPolicyRecord) { + return "No readiness policy record was recorded for this run."; + } + + const record = inspection.readinessPolicyRecord; + return [ + "Readiness policy:", + `- mode: ${record.mode}`, + `- readiness: ${record.readiness}`, + `- summary: ${record.summary}`, + `- headline: ${record.postReadinessSummary.headline}`, + "", + "Requirements:", + ...record.requirements.map( + (requirement) => `- ${requirement.name}: ${requirement.status}${requirement.summary ? ` (${requirement.summary})` : ""}` + ), + "", + "Classified blockers:", + ...(record.classifiedBlockers.length > 0 + ? record.classifiedBlockers.map( + (blocker) => `- ${blocker.category}: ${blocker.summary}${blocker.status ? ` [${blocker.status}]` : ""}` + ) + : ["- none"]), + "", + "Post-readiness next actions:", + ...(record.postReadinessSummary.nextActions.length > 0 + ? record.postReadinessSummary.nextActions.map((action) => `- ${action}`) + : ["- none"]) + ].join("\n"); +} + +function renderDeploymentEvidence(inspection: RunInspection): string { + if (!inspection.deploymentEvidenceRecord) { + return "No deployment evidence record was recorded for this run."; + } + + const record = inspection.deploymentEvidenceRecord; + return [ + "Deployment evidence:", + `- status: ${record.status}`, + `- mode: ${record.mode}`, + `- summary: ${record.summary}`, + "", + "References:", + ...(record.references.length > 0 + ? record.references.map((reference) => `- ${reference.kind}: ${reference.label} (${reference.status})${reference.url ? ` ${reference.url}` : ""}`) + : ["- none"]), + "", + "Blockers:", + ...(record.blockers.length > 0 ? record.blockers.map((blocker) => `- ${blocker}`) : ["- none"]) + ].join("\n"); +} + +function renderPostShipEvidence(inspection: RunInspection): string { + if (!inspection.postShipEvidenceRecord) { + return "No post-ship evidence was recorded for this run."; + } + + return [ + "Post-ship evidence:", + `- status: ${inspection.postShipEvidenceRecord.status}`, + `- summary: ${inspection.postShipEvidenceRecord.summary}`, + `- observed at: ${inspection.postShipEvidenceRecord.observedAt}`, + `- follow-up required: ${inspection.postShipEvidenceRecord.followUpRequired ? "yes" : "no"}`, + `- source artifacts: ${inspection.postShipEvidenceRecord.sourceArtifacts.join(", ") || "none"}`, + "", + "Observed signals:", + ...inspection.postShipEvidenceRecord.observedSignals.map((signal) => `- ${signal.kind}: ${signal.status} (${signal.summary})`), + ...(inspection.postShipEvidenceRecord.inferredRecommendations.length > 0 + ? [ + "Inferred recommendations:", + ...inspection.postShipEvidenceRecord.inferredRecommendations.map((entry) => `- ${entry}`) + ] + : ["No inferred recommendations."]), + "", + `${JSON.stringify(inspection.postShipEvidenceRecord, null, 2)}` + ].join("\n"); +} + +function renderPostShipFollowUp(inspection: RunInspection): string { + if (!inspection.postShipFollowUpRecord) { + return "No post-ship follow-up record was recorded for this run."; + } + + return [ + "Post-ship follow-up:", + `- status: ${inspection.postShipFollowUpRecord.status}`, + `- source run: ${inspection.postShipFollowUpRecord.sourceRun.runId} (${inspection.postShipFollowUpRecord.sourceRun.workflow})`, + `- linked issues: ${inspection.postShipFollowUpRecord.linkedIssueNumbers.join(", ") || "none"}`, + "", + ...inspection.postShipFollowUpRecord.recommendedDrafts.length > 0 + ? ["Recommended drafts:", ...inspection.postShipFollowUpRecord.recommendedDrafts.map((draft) => `- ${draft.priority} ${draft.title}: ${draft.reason}`)] + : ["No recommended follow-up drafts."], + "", + `${JSON.stringify(inspection.postShipFollowUpRecord, null, 2)}` + ].join("\n"); +} + function renderGitHub(inspection: RunInspection): string { if (!inspection.githubDeliveryRecord) { return "No GitHub delivery record was recorded for this run."; @@ -1493,6 +1833,16 @@ function renderWhatRemains(inspection: RunInspection): string { if (outstandingStages.length === 0 && skippedSpecialists.length === 0) { appendGitHubBlockers(); + if (inspection.postShipEvidenceRecord?.followUpRequired) { + lines.push(...inspection.postShipEvidenceRecord.inferredRecommendations.map((entry) => `- post-ship follow-up: ${entry}`)); + } + if (inspection.postShipFollowUpRecord?.recommendedDrafts.length) { + lines.push( + ...inspection.postShipFollowUpRecord.recommendedDrafts.map( + (draft) => `- follow-up draft: ${draft.title} [${draft.priority}]` + ) + ); + } if (lines.length > 1) { return lines.join("\n"); } @@ -1521,6 +1871,16 @@ function renderWhatRemains(inspection: RunInspection): string { lines.push(`- specialist ${specialist.name}: planned but not executed`); } appendGitHubBlockers(); + if (inspection.postShipEvidenceRecord?.followUpRequired) { + lines.push(...inspection.postShipEvidenceRecord.inferredRecommendations.map((entry) => `- post-ship follow-up: ${entry}`)); + } + if (inspection.postShipFollowUpRecord?.recommendedDrafts.length) { + lines.push( + ...inspection.postShipFollowUpRecord.recommendedDrafts.map( + (draft) => `- follow-up draft: ${draft.title} [${draft.priority}]` + ) + ); + } return lines.join("\n"); } @@ -1584,6 +1944,9 @@ function buildInspectorCompletionContext(inspection: RunInspection): InspectorCo "show research", "show session", "show verification", + "show issue", + "show capabilities", + "show initiative", "show validation", "show pyramid", "show coverage", @@ -1592,6 +1955,10 @@ function buildInspectorCompletionContext(inspection: RunInspection): InspectorCo "show review", "show mitigations", "show ship", + "show readiness", + "show deployment", + "show post-ship", + "show follow-up", "show mutation", "show github", "show branch", @@ -1622,6 +1989,9 @@ function buildInspectorCompletionContext(inspection: RunInspection): InspectorCo "research", "session", "verification", + "issue", + "capabilities", + "initiative", "validation", "pyramid", "coverage", @@ -1630,6 +2000,10 @@ function buildInspectorCompletionContext(inspection: RunInspection): InspectorCo "review", "mitigations", "ship", + "readiness", + "deployment", + "post-ship", + "follow-up", "mutation", "github", "branch", @@ -1768,6 +2142,9 @@ function helpText(): string { "- show research", "- show session", "- show verification", + "- show issue", + "- show capabilities", + "- show initiative", "- show validation", "- show pyramid", "- show coverage", @@ -1776,6 +2153,10 @@ function helpText(): string { "- show review", "- show mitigations", "- show ship", + "- show readiness", + "- show deployment", + "- show post-ship", + "- show follow-up", "- show mutation", "- show github", "- show branch", @@ -1847,6 +2228,15 @@ export async function executeInspectorCommand(cwd: string, inspection: RunInspec if (trimmed === "show verification") { return { output: renderVerification(inspection) }; } + if (trimmed === "show issue") { + return { output: renderPlanningIssue(inspection) }; + } + if (trimmed === "show capabilities") { + return { output: renderCapabilities(inspection) }; + } + if (trimmed === "show initiative") { + return { output: renderInitiative(inspection) }; + } if (trimmed === "show validation") { return { output: renderValidation(inspection) }; } @@ -1871,6 +2261,18 @@ export async function executeInspectorCommand(cwd: string, inspection: RunInspec if (trimmed === "show ship") { return { output: renderDeliverShip(inspection) }; } + if (trimmed === "show readiness") { + return { output: renderReadinessPolicy(inspection) }; + } + if (trimmed === "show deployment") { + return { output: renderDeploymentEvidence(inspection) }; + } + if (trimmed === "show post-ship") { + return { output: renderPostShipEvidence(inspection) }; + } + if (trimmed === "show follow-up") { + return { output: renderPostShipFollowUp(inspection) }; + } if (trimmed === "show mutation") { return { output: renderGitHubMutation(inspection) }; } @@ -2148,11 +2550,13 @@ export function renderRunLedger(entries: RunLedgerEntry[]): string { return "No cstack runs found.\n"; } - const headers = ["run_id", "workflow", "status", "stage", "updated_at", "specialists", "summary"]; + const headers = ["run_id", "workflow", "status", "issue", "initiative", "stage", "updated_at", "specialists", "summary"]; const rows = entries.map((entry) => [ entry.id, entry.workflow, entry.status, + entry.planningIssueNumber ? `#${entry.planningIssueNumber}` : "-", + formatInitiativeLabel(entry.initiativeId, entry.initiativeTitle), entry.currentStage ?? "-", formatLedgerTimestamp(entry.updatedAt), entry.activeSpecialists.join(",") || "-", @@ -2174,12 +2578,16 @@ export async function loadRunLedger(cwd: string, options: { activeOnly?: boolean; workflow?: RunLedgerEntry["workflow"]; status?: RunLedgerEntry["status"]; + planningIssueNumber?: number; + initiativeId?: string; recent?: number; }): Promise<RunLedgerEntry[]> { const query: { activeOnly?: boolean; workflow?: RunLedgerEntry["workflow"]; status?: RunLedgerEntry["status"]; + planningIssueNumber?: number; + initiativeId?: string; recent?: number; } = {}; @@ -2192,6 +2600,12 @@ export async function loadRunLedger(cwd: string, options: { if (options.status) { query.status = options.status; } + if (typeof options.planningIssueNumber === "number") { + query.planningIssueNumber = options.planningIssueNumber; + } + if (typeof options.initiativeId === "string") { + query.initiativeId = options.initiativeId; + } if (typeof options.recent === "number") { query.recent = options.recent; } diff --git a/src/intent.ts b/src/intent.ts index 1dec859..fe736fc 100644 --- a/src/intent.ts +++ b/src/intent.ts @@ -969,10 +969,16 @@ export async function runIntent(cwd: string, intent: string, options: IntentComm } }); discoverFindings = discoverResult.finalBody; + if (discoverResult.status === "failed") { + throw new Error(discoverResult.notes[0] ?? `Discover failed closed with exit code ${discoverResult.leadResult.code}`); + } lineageStage.status = "completed"; lineageStage.executed = true; lineageStage.stageDir = stageDir; lineageStage.artifactPath = path.join(stageDir, "artifacts", "findings.md"); + if (discoverResult.status === "partial") { + lineageStage.notes = discoverResult.notes[0] ?? "Discover recovered a partial artifact after a non-zero lead exit."; + } events.markStage(stageName, "completed"); await writeJson(stageLineagePath, stageLineage); continue; @@ -1128,11 +1134,31 @@ export async function runIntent(cwd: string, intent: string, options: IntentComm await maybeOfferInteractiveInspect(cwd, runId); return runId; } catch (error) { + const failureMessage = error instanceof Error ? error.message : String(error); + const failedStage = runRecord.currentStage; + if (failedStage) { + if (failedStage.startsWith("specialist:")) { + const specialistName = failedStage.slice("specialist:".length); + const lineageSpecialist = stageLineage.specialists.find((specialist) => specialist.name === specialistName); + if (lineageSpecialist) { + lineageSpecialist.status = "failed"; + lineageSpecialist.notes = failureMessage; + } + } else { + const lineageStage = stageLineage.stages.find((stage) => stage.name === failedStage); + if (lineageStage) { + lineageStage.status = "failed"; + lineageStage.executed = true; + lineageStage.notes = failureMessage; + } + } + await writeJson(stageLineagePath, stageLineage); + } runRecord.status = "failed"; runRecord.updatedAt = new Date().toISOString(); delete runRecord.currentStage; runRecord.activeSpecialists = []; - runRecord.error = error instanceof Error ? error.message : String(error); + runRecord.error = failureMessage; await writeRunRecord(runDir, runRecord); await events.emit("failed", runRecord.error); throw error; diff --git a/src/post-ship.ts b/src/post-ship.ts new file mode 100644 index 0000000..64bd620 --- /dev/null +++ b/src/post-ship.ts @@ -0,0 +1,173 @@ +import type { + DeliverShipRecord, + GitHubDeliveryRecord, + PostShipEvidenceRecord, + PostShipFollowUpRecord, + PostShipObservedSignal, + WorkflowName +} from "./types.js"; + +function pushUnique(target: string[], value: string): void { + if (value && !target.includes(value)) { + target.push(value); + } +} + +function buildObservedSignals(shipRecord: DeliverShipRecord, githubDeliveryRecord: GitHubDeliveryRecord): PostShipObservedSignal[] { + return [ + { + kind: "ship-readiness", + status: shipRecord.readiness === "ready" ? "ready" : "blocked", + summary: shipRecord.summary + }, + { + kind: "github-delivery", + status: githubDeliveryRecord.overall.status, + summary: githubDeliveryRecord.overall.summary + }, + { + kind: "issues", + status: githubDeliveryRecord.issues.status, + summary: githubDeliveryRecord.issues.summary + }, + { + kind: "checks", + status: githubDeliveryRecord.checks.status, + summary: githubDeliveryRecord.checks.summary + }, + { + kind: "actions", + status: githubDeliveryRecord.actions.status, + summary: githubDeliveryRecord.actions.summary + }, + { + kind: "release", + status: githubDeliveryRecord.release.status, + summary: githubDeliveryRecord.release.summary + }, + { + kind: "security", + status: githubDeliveryRecord.security.status, + summary: githubDeliveryRecord.security.summary + } + ]; +} + +export function buildPostShipArtifacts(options: { + runId: string; + workflow: WorkflowName; + shipRecord: DeliverShipRecord; + githubDeliveryRecord: GitHubDeliveryRecord; +}): { + evidenceRecord: PostShipEvidenceRecord; + followUpRecord: PostShipFollowUpRecord; + summaryMarkdown: string; + followUpDraftMarkdown: string; +} { + const { runId, workflow, shipRecord, githubDeliveryRecord } = options; + const observedSignals = buildObservedSignals(shipRecord, githubDeliveryRecord); + const inferredRecommendations: string[] = []; + + if (shipRecord.readiness !== "ready") { + pushUnique(inferredRecommendations, "Resolve the recorded ship blockers before treating the change as stable."); + } + if (githubDeliveryRecord.issues.status === "blocked") { + pushUnique(inferredRecommendations, "Create or update a follow-up issue for the linked issue state before rerunning ship or deliver."); + } + if (githubDeliveryRecord.checks.status === "blocked") { + pushUnique(inferredRecommendations, "Create a follow-up to restore the blocked required checks and rerun delivery verification."); + } + if (githubDeliveryRecord.actions.status === "blocked") { + pushUnique(inferredRecommendations, "Review the blocked GitHub Actions runs and capture the next remediation slice."); + } + if (githubDeliveryRecord.release.status === "blocked") { + pushUnique(inferredRecommendations, "Create a release-evidence follow-up before treating the release path as complete."); + } + if (githubDeliveryRecord.security.status === "blocked") { + pushUnique(inferredRecommendations, "Create a remediation follow-up for the blocked security gate before progressing the release path."); + } + + const status: PostShipEvidenceRecord["status"] = + inferredRecommendations.length > 0 + ? "follow-up-required" + : observedSignals.some((signal) => signal.status === "unknown") + ? "signal-unavailable" + : "stable"; + + const summary = + status === "stable" + ? "Post-ship evidence is stable based on the recorded ship and GitHub delivery artifacts." + : status === "signal-unavailable" + ? "Post-ship evidence is incomplete because some downstream signals were unavailable." + : "Post-ship follow-up is required based on the recorded ship and GitHub delivery blockers."; + + const recommendedDrafts = inferredRecommendations.map((recommendation, index) => ({ + title: + githubDeliveryRecord.issueReferences.length > 0 + ? `Follow-up for linked issue #${githubDeliveryRecord.issueReferences[0]} (${index + 1})` + : `Post-ship follow-up ${index + 1}`, + reason: recommendation, + priority: + recommendation.toLowerCase().includes("security") || recommendation.toLowerCase().includes("blocked required checks") + ? ("high" as const) + : ("medium" as const) + })); + + const sourceArtifacts = + options.workflow === "deliver" + ? [ + "artifacts/ship-record.json", + "artifacts/github-delivery.json", + "artifacts/github-mutation.json", + "stages/ship/artifacts/ship-record.json", + "stages/ship/artifacts/github-delivery.json", + "stages/ship/artifacts/github-mutation.json" + ] + : ["artifacts/ship-record.json", "artifacts/github-delivery.json", "artifacts/github-mutation.json"]; + + const evidenceRecord: PostShipEvidenceRecord = { + status, + summary, + observedAt: new Date().toISOString(), + observedSignals, + inferredRecommendations, + followUpRequired: inferredRecommendations.length > 0, + sourceArtifacts: [...new Set(sourceArtifacts)] + }; + + const followUpRecord: PostShipFollowUpRecord = { + status: recommendedDrafts.length > 0 ? "recommended" : "none", + sourceRun: { + runId, + workflow + }, + linkedIssueNumbers: githubDeliveryRecord.issueReferences, + recommendedDrafts + }; + + const summaryMarkdown = [ + "# Post-Ship Summary", + "", + `- status: ${evidenceRecord.status}`, + `- summary: ${evidenceRecord.summary}`, + ...evidenceRecord.observedSignals.map((signal) => `- ${signal.kind}: ${signal.status} (${signal.summary})`), + "", + "## Follow-up recommendations", + ...(evidenceRecord.inferredRecommendations.length > 0 ? evidenceRecord.inferredRecommendations.map((entry) => `- ${entry}`) : ["- none"]) + ].join("\n") + "\n"; + + const followUpDraftMarkdown = [ + "# Post-Ship Follow-Up Draft", + "", + ...(recommendedDrafts.length > 0 + ? recommendedDrafts.flatMap((draft) => [`## ${draft.title}`, "", `- priority: ${draft.priority}`, `- reason: ${draft.reason}`, ""]) + : ["No follow-up draft is required from the current post-ship evidence."]) + ].join("\n") + "\n"; + + return { + evidenceRecord, + followUpRecord, + summaryMarkdown, + followUpDraftMarkdown + }; +} diff --git a/src/prompt.ts b/src/prompt.ts index 47e12c9..073f408 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -89,6 +89,21 @@ function buildReferencePromptLines(referenceFiles: PromptReferenceFile[], option return lines; } +function summarizeDiscoverDelegates(delegateResults: DiscoverDelegateResult[]): Array<Record<string, unknown>> { + return delegateResults.map((delegate) => ({ + track: delegate.track, + status: delegate.status, + disposition: delegate.leaderDisposition, + summary: delegate.summary, + topFindings: delegate.findings.slice(0, 3), + unresolved: delegate.unresolved.slice(0, 3), + filesInspected: delegate.filesInspected.slice(0, 6), + commandsRun: delegate.commandsRun.slice(0, 6), + sourceCount: delegate.sources.length, + notes: delegate.notes ?? "" + })); +} + async function buildWorkflowPrompt(options: { cwd: string; input: string; @@ -133,19 +148,39 @@ async function buildWorkflowPrompt(options: { return { prompt, context }; } -export async function buildSpecPrompt(cwd: string, input: string, config: CstackConfig): Promise<{ prompt: string; context: string }> { +export async function buildSpecPrompt( + cwd: string, + input: string, + config: CstackConfig, + options: { planningIssueNumber?: number; initiativeId?: string; initiativeTitle?: string } = {} +): Promise<{ prompt: string; context: string }> { const { prompt, context } = await buildWorkflowPrompt({ cwd, input, workflow: "spec", config }); return { prompt: [ prompt, "", + "## Linked planning issue", + options.planningIssueNumber ? `- GitHub issue: #${options.planningIssueNumber}` : "- none", + "", + "## Initiative", + options.initiativeId ? `- initiative: ${options.initiativeId}` : "- none", + options.initiativeTitle ? `- title: ${options.initiativeTitle}` : "- no title provided", + "", "## Spec execution contract", "- produce an implementation-ready plan, not an exhaustive repo audit", "- if the request is broad, choose the highest-leverage first remediation slice", + "- for mixed gap-analysis plus remediation prompts, rank the top 1-3 gap clusters briefly and then select exactly one slice to implement first", + "- the chosen slice must fit in one bounded change set with named files, validation, and out-of-scope boundaries", + "- avoid multi-epic roadmaps, repo-wide rewrites, or parallel workstreams in the first slice", "- rely on provided discover findings and a representative sample of repo files instead of re-scanning everything", "- if evidence is incomplete, record bounded open questions and stop" ].join("\n"), - context + context: [ + context, + `Planning issue: ${options.planningIssueNumber ? `#${options.planningIssueNumber}` : "none"}`, + `Initiative id: ${options.initiativeId ?? "none"}`, + `Initiative title: ${options.initiativeTitle ?? "none"}` + ].join("\n") }; } @@ -312,9 +347,12 @@ export async function buildDiscoverTrackPrompt(options: { "Workflow: discover", `Track: ${track}`, `Reason: ${reason}`, + typeof plan.planningIssueNumber === "number" ? `Planning issue: #${plan.planningIssueNumber}` : undefined, `Web research allowed: ${plan.webResearchAllowed ? "yes" : "no"}`, ...buildReferenceContextLines(referenceFiles) - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); const prompt = [ `You are the \`${track}\` track in a bounded \`cstack discover\` research run.`, @@ -325,8 +363,12 @@ export async function buildDiscoverTrackPrompt(options: { "- stay inside this track's scope", "- be concrete and operational", "- do not write implementation code", + "- inspect representative files only; start with manifests, README/docs, CI workflows, entrypoints, tests, and contracts before deep source scans", + "- stop once you can name the top 3 gaps or first remediation candidates with evidence", + "- cap shell activity to a small bounded sample; prefer at most 8 commands and at most 12 files inspected", "- if web research is not allowed, stay local to the repository and provided docs", "- if web research is allowed and needed, cite sources explicitly with stable URLs when possible", + "- if the time or evidence budget is insufficient, return partial findings and unresolved items instead of continuing to scan", "- return valid JSON only, with no markdown fences or commentary", "", "Required JSON shape:", @@ -344,6 +386,9 @@ export async function buildDiscoverTrackPrompt(options: { "## User request", input, "", + ...(typeof plan.planningIssueNumber === "number" + ? ["## Linked planning issue", `- GitHub issue: #${plan.planningIssueNumber}`, ""] + : []), "## Track activation reason", reason, "", @@ -369,9 +414,12 @@ export async function buildDiscoverLeadPrompt(options: { "Workflow: discover", "Role: Research Lead", `Delegated tracks: ${delegateResults.map((result) => result.track).join(", ") || "none"}`, + typeof plan.planningIssueNumber === "number" ? `Planning issue: #${plan.planningIssueNumber}` : undefined, `Web research allowed: ${plan.webResearchAllowed ? "yes" : "no"}`, ...buildReferenceContextLines(referenceFiles) - ].join("\n"); + ] + .filter(Boolean) + .join("\n"); const prompt = [ "You are the `Research Lead` for a bounded `cstack discover` run.", @@ -400,11 +448,14 @@ export async function buildDiscoverLeadPrompt(options: { "## User request", input, "", + ...(typeof plan.planningIssueNumber === "number" + ? ["## Linked planning issue", `- GitHub issue: #${plan.planningIssueNumber}`, ""] + : []), "## Discover research plan", JSON.stringify(plan, null, 2), "", "## Delegate results", - JSON.stringify(delegateResults, null, 2), + JSON.stringify(summarizeDiscoverDelegates(delegateResults), null, 2), "", ...buildReferencePromptLines(referenceFiles) ].join("\n"); diff --git a/src/review.ts b/src/review.ts index d18fbe5..34f0d66 100644 --- a/src/review.ts +++ b/src/review.ts @@ -1,6 +1,6 @@ import path from "node:path"; import { promises as fs } from "node:fs"; -import { runCodexExec } from "./codex.js"; +import { readCodexFinalOutput, runCodexExec } from "./codex.js"; import { resolveLinkedBuildContext } from "./build.js"; import { buildDeliverReviewLeadPrompt, buildDeliverSpecialistPrompt } from "./prompt.js"; import { inferRoutingPlan } from "./intent.js"; @@ -20,6 +20,8 @@ import type { export interface LinkedReviewContext { runId: string; workflow: WorkflowName; + initiativeId?: string | undefined; + initiativeTitle?: string | undefined; artifactPath: string | null; artifactBody: string; buildSummary: string; @@ -174,7 +176,13 @@ async function runReviewSpecialist(options: { stderrPath, config: options.config }); - const finalBody = await fs.readFile(finalPath, "utf8"); + const finalBody = await readCodexFinalOutput({ + context: `Review specialist ${options.specialist.name}`, + finalPath, + stdoutPath, + stderrPath, + result + }); await fs.writeFile(artifactPath, finalBody, "utf8"); return { @@ -269,7 +277,9 @@ export async function resolveLinkedReviewContext(cwd: string, runId: string): Pr if (linked.run.workflow === "build") { return { runId: linked.run.id, - workflow: linked.run.workflow, + workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: linked.artifactPath, artifactBody: linked.artifactBody, buildSummary: linked.artifactBody, @@ -288,6 +298,8 @@ export async function resolveLinkedReviewContext(cwd: string, runId: string): Pr return { runId: linked.run.id, workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: buildSummaryPath, artifactBody: buildSummary, buildSummary, @@ -300,6 +312,8 @@ export async function resolveLinkedReviewContext(cwd: string, runId: string): Pr return { runId: linked.run.id, workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: linked.artifactPath, artifactBody: linked.artifactBody, buildSummary: linked.artifactBody, @@ -372,7 +386,13 @@ export async function runReviewExecution(options: ReviewExecutionOptions): Promi stderrPath: options.paths.stderrPath, config: options.config }); - const reviewRaw = await fs.readFile(options.paths.finalPath, "utf8"); + const reviewRaw = await readCodexFinalOutput({ + context: "Review lead", + finalPath: options.paths.finalPath, + stdoutPath: options.paths.stdoutPath, + stderrPath: options.paths.stderrPath, + result: reviewResult + }); const reviewVerdict = parseJson<DeliverReviewVerdict>(reviewRaw, "Review lead"); await fs.writeFile(options.paths.findingsPath, reviewVerdict.reportMarkdown, "utf8"); diff --git a/src/run.ts b/src/run.ts index 3c8fb97..5b37f48 100644 --- a/src/run.ts +++ b/src/run.ts @@ -124,7 +124,10 @@ export async function buildRunLedgerEntry(cwd: string, run: RunRecord): Promise< summary: summarizeRun(run), currentStage: run.currentStage ?? runningStage?.name, activeSpecialists, - finalPath: run.finalPath + finalPath: run.finalPath, + ...(typeof run.inputs?.planningIssueNumber === "number" ? { planningIssueNumber: run.inputs.planningIssueNumber } : {}), + ...(typeof run.inputs?.initiativeId === "string" ? { initiativeId: run.inputs.initiativeId } : {}), + ...(typeof run.inputs?.initiativeTitle === "string" ? { initiativeTitle: run.inputs.initiativeTitle } : {}) }; } @@ -132,6 +135,8 @@ export interface ListRunLedgerOptions { activeOnly?: boolean; status?: RunRecord["status"]; workflow?: WorkflowName; + initiativeId?: string; + planningIssueNumber?: number; recent?: number; } @@ -174,6 +179,12 @@ export async function listRunLedger(cwd: string, options: ListRunLedgerOptions = if (options.workflow) { runs = runs.filter((run) => run.workflow === options.workflow); } + if (options.initiativeId) { + runs = runs.filter((run) => run.inputs?.initiativeId === options.initiativeId); + } + if (typeof options.planningIssueNumber === "number") { + runs = runs.filter((run) => run.inputs?.planningIssueNumber === options.planningIssueNumber); + } if (typeof options.recent === "number") { runs = runs.slice(0, Math.max(options.recent, 0)); diff --git a/src/ship.ts b/src/ship.ts index 699fdc0..ec2b86d 100644 --- a/src/ship.ts +++ b/src/ship.ts @@ -1,17 +1,24 @@ import path from "node:path"; import { promises as fs } from "node:fs"; import { resolveLinkedBuildContext } from "./build.js"; -import { runCodexExec } from "./codex.js"; +import { readCodexFinalOutput, runCodexExec } from "./codex.js"; +import { buildPostShipArtifacts } from "./post-ship.js"; import { buildDeliverShipPrompt } from "./prompt.js"; import { collectGitHubDeliveryEvidence, performGitHubDeliverMutations } from "./github.js"; import type { BuildVerificationRecord, CstackConfig, + DeliveryReadinessBlocker, + DeliveryReadinessBlockerCategory, + DeliveryReadinessPolicyRecord, + DeploymentEvidenceRecord, DeliverReviewVerdict, DeliverShipRecord, DeliverTargetMode, GitHubDeliveryRecord, GitHubMutationRecord, + PostShipEvidenceRecord, + PostShipFollowUpRecord, StageLineage, WorkflowName } from "./types.js"; @@ -19,6 +26,8 @@ import type { export interface LinkedShipContext { runId: string; workflow: WorkflowName; + initiativeId?: string | undefined; + initiativeTitle?: string | undefined; artifactPath: string | null; artifactBody: string; buildSummary: string; @@ -38,6 +47,12 @@ export interface ShipPaths { checklistPath: string; unresolvedPath: string; shipRecordPath: string; + readinessPolicyPath: string; + deploymentEvidencePath: string; + postShipSummaryPath: string; + postShipEvidencePath: string; + followUpDraftPath: string; + followUpLineagePath: string; githubMutationPath: string; githubDeliveryPath: string; stageLineagePath: string; @@ -60,6 +75,8 @@ export interface ShipExecutionResult { shipRecord: DeliverShipRecord; githubMutationRecord: GitHubMutationRecord; githubDeliveryRecord: GitHubDeliveryRecord; + postShipEvidenceRecord: PostShipEvidenceRecord; + postShipFollowUpRecord: PostShipFollowUpRecord; stageLineage: StageLineage; finalBody: string; } @@ -100,6 +117,252 @@ function mergeUniqueLines(values: string[]): string[] { return [...new Set(values.filter(Boolean))]; } +function deliverRequirementStatus(options: { + required: boolean; + blocked: boolean; + satisfied: boolean; +}): DeliveryReadinessPolicyRecord["requirements"][number]["status"] { + if (!options.required) { + return "not-applicable"; + } + if (options.blocked) { + return "blocked"; + } + return options.satisfied ? "satisfied" : "missing"; +} + +function blockerCategoryForRequirement( + name: DeliveryReadinessPolicyRecord["requirements"][number]["name"] +): DeliveryReadinessBlockerCategory { + switch (name) { + case "review-verdict": + return "review-evidence"; + case "ship-readiness": + return "ship-output"; + case "github-delivery": + return "github-delivery"; + case "linked-issues": + return "linked-issues"; + case "release-evidence": + return "release-evidence"; + case "deployment-evidence": + return "deployment-evidence"; + } +} + +function classifyReadinessBlockers( + requirements: DeliveryReadinessPolicyRecord["requirements"] +): DeliveryReadinessBlocker[] { + return requirements + .filter( + (requirement): requirement is typeof requirement & { status: Exclude<typeof requirement.status, "satisfied" | "not-applicable"> } => + requirement.status === "blocked" || requirement.status === "missing" + ) + .map((requirement) => ({ + category: blockerCategoryForRequirement(requirement.name), + requirement: requirement.name, + status: requirement.status, + summary: requirement.summary, + evidence: requirement.evidence + })); +} + +function buildPostReadinessSummary(options: { + shipRecord: DeliverShipRecord; + classifiedBlockers: DeliveryReadinessBlocker[]; +}): DeliveryReadinessPolicyRecord["postReadinessSummary"] { + const blockerLabels = options.classifiedBlockers.map((blocker) => `${blocker.category}: ${blocker.summary}`); + return { + status: options.shipRecord.readiness, + headline: + blockerLabels.length > 0 + ? `Final delivery is blocked by ${blockerLabels.length} readiness categor${blockerLabels.length === 1 ? "y" : "ies"}.` + : "Final delivery readiness is satisfied.", + highlights: [ + `Ship readiness: ${options.shipRecord.readiness}`, + ...options.shipRecord.checklist + .filter((item) => item.status === "complete") + .slice(0, 3) + .map((item) => `${item.item}${item.notes ? ` (${item.notes})` : ""}`) + ], + blockers: blockerLabels, + nextActions: mergeUniqueLines(options.shipRecord.nextActions).slice(0, 8) + }; +} + +export function buildDeploymentEvidenceRecord(options: { + deliveryMode: DeliverTargetMode; + githubDeliveryRecord: GitHubDeliveryRecord; +}): DeploymentEvidenceRecord { + const { deliveryMode, githubDeliveryRecord } = options; + const references = [ + ...(githubDeliveryRecord.pullRequest.observed?.url + ? [ + { + kind: "pull-request" as const, + label: `PR #${githubDeliveryRecord.pullRequest.observed.number}`, + status: githubDeliveryRecord.pullRequest.status, + url: githubDeliveryRecord.pullRequest.observed.url + } + ] + : []), + ...githubDeliveryRecord.issues.observed.map((issue) => ({ + kind: "issue" as const, + label: `Issue #${issue.number}`, + status: issue.state.toLowerCase(), + ...(issue.url ? { url: issue.url } : {}) + })), + ...githubDeliveryRecord.checks.observed.map((check) => ({ + kind: "check" as const, + label: check.name, + status: check.conclusion ?? check.status, + ...(check.detailsUrl ? { url: check.detailsUrl } : {}) + })), + ...githubDeliveryRecord.actions.observed.map((action) => ({ + kind: "action" as const, + label: action.workflowName, + status: action.conclusion ?? action.status, + ...(action.url ? { url: action.url } : {}) + })), + ...(githubDeliveryRecord.release.observed?.url + ? [ + { + kind: "release" as const, + label: githubDeliveryRecord.release.observed.tagName, + status: githubDeliveryRecord.release.status, + url: githubDeliveryRecord.release.observed.url + } + ] + : []) + ]; + + const blockers = [ + ...githubDeliveryRecord.pullRequest.blockers, + ...githubDeliveryRecord.checks.blockers, + ...githubDeliveryRecord.actions.blockers, + ...githubDeliveryRecord.release.blockers + ]; + + return { + mode: deliveryMode, + generatedAt: new Date().toISOString(), + summary: + references.length > 0 + ? `Recorded ${references.length} deployment-adjacent evidence reference${references.length === 1 ? "" : "s"}.` + : "No deployment-adjacent evidence references were observed.", + blockers, + references, + status: references.length > 0 ? "recorded" : "missing" + }; +} + +export function buildReadinessPolicyRecord(options: { + deliveryMode: DeliverTargetMode; + reviewVerdict: DeliverReviewVerdict; + shipRecord: DeliverShipRecord; + githubDeliveryRecord: GitHubDeliveryRecord; + deploymentEvidenceRecord: DeploymentEvidenceRecord; +}): DeliveryReadinessPolicyRecord { + const { deliveryMode, reviewVerdict, shipRecord, githubDeliveryRecord, deploymentEvidenceRecord } = options; + const releaseRequired = githubDeliveryRecord.release.required || deliveryMode === "release"; + const linkedIssuesRequired = githubDeliveryRecord.issues.required; + const deploymentEvidenceRequired = deliveryMode === "release"; + const deploymentEvidenceSatisfied = + deploymentEvidenceRecord.references.some((reference) => reference.kind === "release" || reference.kind === "action"); + + const requirements: DeliveryReadinessPolicyRecord["requirements"] = [ + { + name: "review-verdict", + required: true, + status: deliverRequirementStatus({ + required: true, + blocked: reviewVerdict.status !== "ready", + satisfied: reviewVerdict.status === "ready" + }), + summary: reviewVerdict.summary, + evidence: [reviewVerdict.summary] + }, + { + name: "ship-readiness", + required: true, + status: deliverRequirementStatus({ + required: true, + blocked: shipRecord.readiness !== "ready", + satisfied: shipRecord.readiness === "ready" + }), + summary: shipRecord.summary, + evidence: [shipRecord.summary] + }, + { + name: "github-delivery", + required: true, + status: deliverRequirementStatus({ + required: true, + blocked: githubDeliveryRecord.overall.status !== "ready", + satisfied: githubDeliveryRecord.overall.status === "ready" + }), + summary: githubDeliveryRecord.overall.summary, + evidence: [...githubDeliveryRecord.overall.blockers] + }, + { + name: "linked-issues", + required: linkedIssuesRequired, + status: deliverRequirementStatus({ + required: linkedIssuesRequired, + blocked: linkedIssuesRequired && githubDeliveryRecord.issues.status === "blocked", + satisfied: githubDeliveryRecord.issues.status === "ready" + }), + summary: githubDeliveryRecord.issues.summary, + evidence: githubDeliveryRecord.issues.observed.map((issue) => `#${issue.number} ${issue.state}`) + }, + { + name: "release-evidence", + required: releaseRequired, + status: deliverRequirementStatus({ + required: releaseRequired, + blocked: releaseRequired && githubDeliveryRecord.release.status === "blocked", + satisfied: githubDeliveryRecord.release.status === "ready" + }), + summary: githubDeliveryRecord.release.summary, + evidence: githubDeliveryRecord.release.observed ? [githubDeliveryRecord.release.observed.tagName] : [] + }, + { + name: "deployment-evidence", + required: deploymentEvidenceRequired, + status: deliverRequirementStatus({ + required: deploymentEvidenceRequired, + blocked: deploymentEvidenceRequired && !deploymentEvidenceSatisfied && deploymentEvidenceRecord.blockers.length > 0, + satisfied: deploymentEvidenceSatisfied + }), + summary: deploymentEvidenceRecord.summary, + evidence: deploymentEvidenceRecord.references.map((reference) => `${reference.kind}:${reference.label}`) + } + ]; + + const blockers = requirements + .filter((requirement) => requirement.status === "blocked" || requirement.status === "missing") + .map((requirement) => `${requirement.name}: ${requirement.summary}`); + const classifiedBlockers = classifyReadinessBlockers(requirements); + const postReadinessSummary = buildPostReadinessSummary({ + shipRecord, + classifiedBlockers + }); + + return { + mode: deliveryMode, + readiness: shipRecord.readiness, + generatedAt: new Date().toISOString(), + summary: + blockers.length > 0 + ? `Readiness policy has ${blockers.length} unmet requirement${blockers.length === 1 ? "" : "s"}.` + : "Readiness policy requirements are satisfied.", + blockers, + requirements, + classifiedBlockers, + postReadinessSummary + }; +} + function notRunVerificationRecord(): BuildVerificationRecord { return { status: "not-run", @@ -161,6 +424,8 @@ export async function resolveLinkedShipContext(cwd: string, runId: string): Prom return { runId: linked.run.id, workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: linked.artifactPath, artifactBody: linked.artifactBody, buildSummary: (await fs.readFile(path.join(runDir, "stages", "build", "artifacts", "change-summary.md"), "utf8").catch(() => linked.artifactBody)) || linked.artifactBody, @@ -181,6 +446,8 @@ export async function resolveLinkedShipContext(cwd: string, runId: string): Prom return { runId: linked.run.id, workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: linked.artifactPath, artifactBody: linked.artifactBody, buildSummary: upstream.buildSummary, @@ -193,6 +460,8 @@ export async function resolveLinkedShipContext(cwd: string, runId: string): Prom return { runId: linked.run.id, workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: linked.artifactPath, artifactBody: linked.artifactBody, buildSummary: linked.artifactBody, @@ -204,6 +473,8 @@ export async function resolveLinkedShipContext(cwd: string, runId: string): Prom return { runId: linked.run.id, workflow: linked.run.workflow, + initiativeId: linked.run.inputs.initiativeId, + initiativeTitle: linked.run.inputs.initiativeTitle, artifactPath: linked.artifactPath, artifactBody: linked.artifactBody, buildSummary: linked.artifactBody, @@ -219,6 +490,7 @@ function buildFinalSummary(options: { shipRecord: DeliverShipRecord; githubMutationRecord: GitHubMutationRecord; githubDeliveryRecord: GitHubDeliveryRecord; + readinessPolicyRecord: DeliveryReadinessPolicyRecord; }): string { return [ "# Ship Run Summary", @@ -237,6 +509,13 @@ function buildFinalSummary(options: { `- summary: ${options.shipRecord.summary}`, ...options.shipRecord.nextActions.map((action) => `- next: ${action}`), "", + "## Post-readiness summary", + `- headline: ${options.readinessPolicyRecord.postReadinessSummary.headline}`, + ...options.readinessPolicyRecord.postReadinessSummary.highlights.map((highlight) => `- highlight: ${highlight}`), + ...(options.readinessPolicyRecord.postReadinessSummary.blockers.length > 0 + ? options.readinessPolicyRecord.postReadinessSummary.blockers.map((blocker) => `- blocker class: ${blocker}`) + : ["- blocker class: none"]), + "", "## GitHub mutations", `- summary: ${options.githubMutationRecord.summary}`, ...(options.githubMutationRecord.pullRequest.url ? [`- pull request: ${options.githubMutationRecord.pullRequest.url}`] : []), @@ -294,6 +573,10 @@ export async function runShipExecution(options: ShipExecutionOptions): Promise<S ...(linkedContext?.artifactBody ? { linkedArtifactBody: linkedContext.artifactBody } : {}) }); const githubDeliveryRecord = githubEvidence.record; + const deploymentEvidenceRecord = buildDeploymentEvidenceRecord({ + deliveryMode: options.deliveryMode, + githubDeliveryRecord + }); const shipPrompt = await buildDeliverShipPrompt({ cwd: options.cwd, @@ -318,7 +601,13 @@ export async function runShipExecution(options: ShipExecutionOptions): Promise<S stderrPath: options.paths.stderrPath, config: options.config }); - const shipRaw = await fs.readFile(options.paths.finalPath, "utf8"); + const shipRaw = await readCodexFinalOutput({ + context: "Ship lead", + finalPath: options.paths.finalPath, + stdoutPath: options.paths.stdoutPath, + stderrPath: options.paths.stderrPath, + result: shipResult + }); let shipRecord = parseJson<DeliverShipRecord>(shipRaw, "Ship lead"); if (reviewVerdict.status !== "ready") { @@ -371,10 +660,30 @@ export async function runShipExecution(options: ShipExecutionOptions): Promise<S }; } + const readinessPolicyRecord = buildReadinessPolicyRecord({ + deliveryMode: options.deliveryMode, + reviewVerdict, + shipRecord, + githubDeliveryRecord, + deploymentEvidenceRecord + }); + const postShipArtifacts = buildPostShipArtifacts({ + runId: options.runId, + workflow: "ship", + shipRecord, + githubDeliveryRecord + }); + await fs.writeFile(options.paths.shipSummaryPath, shipRecord.reportMarkdown, "utf8"); await fs.writeFile(options.paths.checklistPath, renderChecklistMarkdown(shipRecord), "utf8"); await fs.writeFile(options.paths.unresolvedPath, renderUnresolvedMarkdown(shipRecord), "utf8"); await writeJson(options.paths.shipRecordPath, shipRecord); + await writeJson(options.paths.readinessPolicyPath, readinessPolicyRecord); + await writeJson(options.paths.deploymentEvidencePath, deploymentEvidenceRecord); + await fs.writeFile(options.paths.postShipSummaryPath, postShipArtifacts.summaryMarkdown, "utf8"); + await writeJson(options.paths.postShipEvidencePath, postShipArtifacts.evidenceRecord); + await fs.writeFile(options.paths.followUpDraftPath, postShipArtifacts.followUpDraftMarkdown, "utf8"); + await writeJson(options.paths.followUpLineagePath, postShipArtifacts.followUpRecord); await writeJson(path.join(options.paths.runDir, "artifacts", "github-state.json"), githubEvidence.artifacts.githubState); await writeJson(path.join(options.paths.runDir, "artifacts", "pull-request.json"), githubEvidence.artifacts.pullRequest); await writeJson(path.join(options.paths.runDir, "artifacts", "issues.json"), githubEvidence.artifacts.issues); @@ -404,6 +713,7 @@ export async function runShipExecution(options: ShipExecutionOptions): Promise<S shipRecord, githubMutationRecord: githubMutation.record, githubDeliveryRecord, + readinessPolicyRecord, ...(linkedContext ? { linkedContext } : {}) }); await fs.writeFile(options.paths.finalPath, finalBody, "utf8"); @@ -412,6 +722,8 @@ export async function runShipExecution(options: ShipExecutionOptions): Promise<S shipRecord, githubMutationRecord: githubMutation.record, githubDeliveryRecord, + postShipEvidenceRecord: postShipArtifacts.evidenceRecord, + postShipFollowUpRecord: postShipArtifacts.followUpRecord, stageLineage, finalBody }; diff --git a/src/types.ts b/src/types.ts index 1274855..b150ffd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,7 @@ export interface DiscoverResearchPlan { prompt: string; decidedAt: string; mode: DiscoverResearchMode; + planningIssueNumber?: number; delegationEnabled: boolean; maxTracks: number; webResearchAllowed: boolean; @@ -78,6 +79,27 @@ export interface DiscoverResearchPlan { limitations: string[]; } +export interface CapabilityPolicyConfig { + allowed?: string[]; + defaultRequested?: string[]; +} + +export interface CapabilityDowngradeRecord { + name: string; + reason: string; +} + +export interface CapabilityUsageRecord { + workflow: WorkflowName; + stage?: StageName; + allowed: string[]; + requested: string[]; + available: string[]; + used: string[]; + downgraded: CapabilityDowngradeRecord[]; + notes?: string[]; +} + export interface RoutingStagePlan { name: StageName; rationale: string; @@ -149,6 +171,9 @@ export interface RunLedgerEntry { currentStage?: string | undefined; activeSpecialists: string[]; finalPath: string; + planningIssueNumber?: number; + initiativeId?: string; + initiativeTitle?: string; } export interface RunInspection { @@ -156,6 +181,9 @@ export interface RunInspection { runDir: string; routingPlan: RoutingPlan | null; stageLineage: StageLineage | null; + planningIssueLineage: PlanningIssueLineageRecord | null; + discoverCapabilitiesRecord: CapabilityUsageRecord | null; + validationCapabilitiesRecord: CapabilityUsageRecord | null; discoverResearchPlan: DiscoverResearchPlan | null; discoverDelegates: DiscoverDelegateResult[]; sessionRecord: BuildSessionRecord | null; @@ -166,8 +194,13 @@ export interface RunInspection { validationLocalRecord: DeliverValidationLocalRecord | null; deliverReviewVerdict: DeliverReviewVerdict | null; deliverShipRecord: DeliverShipRecord | null; + readinessPolicyRecord: DeliveryReadinessPolicyRecord | null; + deploymentEvidenceRecord: DeploymentEvidenceRecord | null; githubDeliveryRecord: GitHubDeliveryRecord | null; githubMutationRecord: GitHubMutationRecord | null; + postShipEvidenceRecord: PostShipEvidenceRecord | null; + postShipFollowUpRecord: PostShipFollowUpRecord | null; + initiativeGraph: InitiativeGraphRecord | null; executionContext: ExecutionContextRecord | null; recentEvents: RunEvent[]; finalBody: string; @@ -179,6 +212,49 @@ export interface RunInspection { childRuns: ChildRunInspection[]; } +export interface PlanningIssueLineageRecord { + planningIssueNumber: number; + planningIssueUrl?: string; + sourceRun?: { + runId: string; + workflow?: WorkflowName; + }; + currentRun: { + runId: string; + workflow: WorkflowName; + }; + downstreamPullRequests: Array<{ + number: number; + url?: string; + state?: string; + }>; + downstreamReleases: Array<{ + tag: string; + url?: string; + state?: string; + }>; +} + +export interface InitiativeGraphRecord { + initiativeId: string; + initiativeTitle?: string; + sourceRun?: { + runId: string; + workflow?: WorkflowName; + summary?: string; + }; + currentRun: { + runId: string; + workflow: WorkflowName; + summary?: string; + }; + relatedRuns: Array<{ + runId: string; + workflow: WorkflowName; + status: RunStatus; + }>; +} + export interface ChildRunInspection { stageName: StageName; run: RunRecord; @@ -235,6 +311,7 @@ export interface WorkflowConfig { enabled?: boolean; allowWeb?: boolean; }; + capabilities?: CapabilityPolicyConfig; validation?: DeliverValidationConfig; github?: DeliverGitHubConfig; } @@ -329,11 +406,23 @@ export interface BuildSessionRecord { finalArtifactObserved: boolean; timedOut?: boolean; timeoutSeconds?: number; + stalled?: boolean; + stallReason?: string; fallbackReason?: string; }; notes?: string[]; } +export type EnvironmentBlockerCategory = + | "network-blocked" + | "registry-unreachable" + | "toolchain-mismatch" + | "host-tool-missing" + | "repo-test-failure" + | "orchestration-timeout" + | "external-service-blocked" + | "unknown"; + export type BuildFailureCategory = | "missing-tool" | "bootstrap-failure" @@ -359,6 +448,7 @@ export interface BuildRecoveryAttemptRecord { export interface BuildFailureDiagnosisRecord { category: BuildFailureCategory; + blockerCategory?: EnvironmentBlockerCategory; summary: string; detail: string; evidence: string[]; @@ -402,12 +492,15 @@ export interface BuildVerificationCommandRecord { durationMs: number; stdoutPath: string; stderrPath: string; + blockerCategory?: EnvironmentBlockerCategory; + blockerDetail?: string; } export interface BuildVerificationRecord { status: "not-run" | "passed" | "failed"; requestedCommands: string[]; results: BuildVerificationCommandRecord[]; + blockerCategories?: EnvironmentBlockerCategory[]; notes?: string; } @@ -461,6 +554,102 @@ export interface DeliverShipRecord { reportMarkdown: string; } +export type DeliveryRequirementStatus = "satisfied" | "missing" | "blocked" | "not-applicable"; +export type DeliveryReadinessBlockerCategory = + | "review-evidence" + | "ship-output" + | "github-delivery" + | "linked-issues" + | "release-evidence" + | "deployment-evidence"; + +export interface DeliveryReadinessRequirement { + name: + | "review-verdict" + | "ship-readiness" + | "github-delivery" + | "linked-issues" + | "release-evidence" + | "deployment-evidence"; + required: boolean; + status: DeliveryRequirementStatus; + summary: string; + evidence: string[]; +} + +export interface DeliveryReadinessBlocker { + category: DeliveryReadinessBlockerCategory; + requirement: DeliveryReadinessRequirement["name"]; + status: Exclude<DeliveryRequirementStatus, "satisfied" | "not-applicable">; + summary: string; + evidence: string[]; +} + +export interface DeliveryPostReadinessSummary { + status: DeliverShipRecord["readiness"]; + headline: string; + highlights: string[]; + blockers: string[]; + nextActions: string[]; +} + +export interface DeliveryReadinessPolicyRecord { + mode: DeliverTargetMode; + readiness: DeliverShipRecord["readiness"]; + generatedAt: string; + summary: string; + blockers: string[]; + requirements: DeliveryReadinessRequirement[]; + classifiedBlockers: DeliveryReadinessBlocker[]; + postReadinessSummary: DeliveryPostReadinessSummary; +} + +export interface DeploymentEvidenceReference { + kind: "pull-request" | "issue" | "check" | "action" | "release"; + label: string; + status: string; + url?: string; +} + +export interface DeploymentEvidenceRecord { + mode: DeliverTargetMode; + generatedAt: string; + summary: string; + blockers: string[]; + references: DeploymentEvidenceReference[]; + status: "recorded" | "missing"; +} + +export interface PostShipObservedSignal { + kind: "ship-readiness" | "github-delivery" | "issues" | "checks" | "actions" | "release" | "security"; + status: "ready" | "blocked" | "not-applicable" | "unknown"; + summary: string; +} + +export interface PostShipEvidenceRecord { + status: "stable" | "follow-up-required" | "signal-unavailable"; + summary: string; + observedAt: string; + observedSignals: PostShipObservedSignal[]; + inferredRecommendations: string[]; + followUpRequired: boolean; + sourceArtifacts: string[]; +} + +export interface PostShipFollowUpRecord { + status: "none" | "recommended"; + sourceRun: { + runId: string; + workflow: WorkflowName; + }; + linkedIssueNumbers: number[]; + recommendedDrafts: Array<{ + title: string; + reason: string; + priority: "high" | "medium"; + }>; +} + export interface ValidationToolCandidate { tool: string; category: string; @@ -533,6 +722,7 @@ export interface ValidationLayerPlan { export interface DeliverValidationPlan { status: "ready" | "partial" | "blocked"; + outcomeCategory: "ready" | "partial" | "blocked-by-build" | "blocked-by-validation"; summary: string; profileSummary: string; layers: ValidationLayerPlan[]; @@ -577,17 +767,21 @@ export interface ValidationCommandRecord { durationMs: number; stdoutPath: string; stderrPath: string; + blockerCategory?: EnvironmentBlockerCategory; + blockerDetail?: string; } export interface DeliverValidationLocalRecord { status: "not-run" | "passed" | "failed"; requestedCommands: string[]; results: ValidationCommandRecord[]; + blockerCategories?: EnvironmentBlockerCategory[]; notes?: string; } export interface ValidationCoverageSummary { status: "ready" | "partial" | "blocked"; + outcomeCategory: DeliverValidationPlan["outcomeCategory"]; confidence: "low" | "medium" | "high"; summary: string; signals: string[]; @@ -775,6 +969,10 @@ export interface RunRecord { delegatedTracks?: string[]; webResearchAllowed?: boolean; linkedRunId?: string; + planningIssueNumber?: number; + planningIssueUrl?: string; + initiativeId?: string; + initiativeTitle?: string; requestedMode?: WorkflowMode; observedMode?: WorkflowMode; verificationCommands?: string[]; diff --git a/src/validation.ts b/src/validation.ts index d571333..c009b9b 100644 --- a/src/validation.ts +++ b/src/validation.ts @@ -2,13 +2,15 @@ import path from "node:path"; import { promises as fs } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { runCodexExec } from "./codex.js"; +import { classifyExecutionBlocker, uniqueBlockerCategories } from "./blockers.js"; +import { readCodexFinalOutput, runCodexExec } from "./codex.js"; import { buildDeliverValidationLeadPrompt, buildDeliverValidationSpecialistPrompt } from "./prompt.js"; import type { BuildVerificationRecord, + CapabilityUsageRecord, CstackConfig, DeliverValidationLocalRecord, DeliverValidationPlan, @@ -235,6 +237,10 @@ function pushUnique(target: string[], values: string[]): void { } } +function unique(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + function classifyWorkspaceSupport(target: { path: string; manifests: string[]; @@ -884,6 +890,7 @@ function buildInitialValidationPlan(profile: ValidationRepoProfile, toolResearch return { status: localCommands.length > 0 ? "ready" : "partial", + outcomeCategory: localCommands.length > 0 ? "ready" : "partial", summary: `Validation planning selected ${layers.filter((layer) => layer.selected).length} pyramid layers.`, profileSummary: `Surfaces: ${profile.surfaces.join(", ") || "unknown"}; build systems: ${profile.buildSystems.join(", ") || "unknown"}; workspace targets: ${profile.workspaceTargets.length}.`, layers, @@ -921,6 +928,93 @@ function buildInitialValidationPlan(profile: ValidationRepoProfile, toolResearch }; } +function buildValidationCapabilityRecord(options: { + config: CstackConfig; + repoProfile: ValidationRepoProfile; + toolResearch: ValidationToolResearch; +}): CapabilityUsageRecord { + const workflowPolicy = options.config.workflows.deliver.capabilities ?? {}; + const allowed = unique(workflowPolicy.allowed ?? []); + const requested = unique([ + ...(workflowPolicy.defaultRequested ?? []), + "shell", + ...(options.repoProfile.workflowFiles.length > 0 ? ["github"] : []), + ...(options.repoProfile.surfaces.includes("web-app") ? ["browser"] : []) + ]); + const downgraded: CapabilityUsageRecord["downgraded"] = []; + const available: string[] = []; + + for (const capability of requested) { + if (allowed.length > 0 && !allowed.includes(capability)) { + downgraded.push({ + name: capability, + reason: "not allowed by workflow capability policy" + }); + continue; + } + if (capability === "browser" && !options.repoProfile.surfaces.includes("web-app")) { + downgraded.push({ + name: capability, + reason: "repo profile does not justify browser capability for this validation stage" + }); + continue; + } + available.push(capability); + } + + const toolNames = options.toolResearch.candidates.filter((candidate) => candidate.selected).map((candidate) => candidate.tool); + const used: string[] = []; + + return { + workflow: "deliver", + stage: "validation", + allowed, + requested, + available, + used, + downgraded, + notes: [ + `repo surfaces: ${options.repoProfile.surfaces.join(", ") || "unknown"}`, + `selected tools: ${toolNames.join(", ") || "none"}` + ] + }; +} + +function inferUsedValidationCapabilities(options: { + localValidationRecord: DeliverValidationLocalRecord; + validationPlan: DeliverValidationPlan; + availableCapabilities: string[]; + selectedToolNames: string[]; +}): string[] { + const used = new Set<string>(); + + if (options.localValidationRecord.status !== "not-run" && options.localValidationRecord.results.length > 0) { + used.add("shell"); + } + + const commandHints = options.localValidationRecord.results + .flatMap((result) => [result.command]) + .concat(options.validationPlan.localValidation.commands) + .map((command) => command.toLowerCase()); + + for (const command of commandHints) { + if (/playwright|cypress|detox|maestro|puppeteer|selenium|e2e|browser/.test(command)) { + used.add("browser"); + break; + } + if (!options.selectedToolNames.includes("playwright") && !options.selectedToolNames.includes("cypress") && /e2e|browser/.test(command)) { + used.add("browser"); + break; + } + } + + if (options.validationPlan.ciValidation.jobs.length > 0 && options.localValidationRecord.status !== "not-run") { + used.add("github"); + } + + return [...used].filter((capability) => options.availableCapabilities.includes(capability)); +} + async function runCommandSet(cwd: string, runDir: string, commands: string[]): Promise<DeliverValidationLocalRecord> { if (commands.length === 0) { return { @@ -957,13 +1051,16 @@ async function runCommandSet(cwd: string, runDir: string, commands: string[]): P const execError = error as NodeJS.ErrnoException & { stdout?: string; stderr?: string; code?: number }; await fs.writeFile(stdoutPath, execError.stdout ?? "", "utf8"); await fs.writeFile(stderrPath, execError.stderr ?? execError.message, "utf8"); + const blocker = classifyExecutionBlocker(command, `${execError.stderr ?? ""}\n${execError.stdout ?? ""}\n${execError.message ?? ""}`); results.push({ command, exitCode: typeof execError.code === "number" ? execError.code : 1, status: "failed", durationMs: Date.now() - startedAt, stdoutPath, - stderrPath + stderrPath, + ...(blocker?.category ? { blockerCategory: blocker.category } : {}), + ...(blocker?.detail ? { blockerDetail: blocker.detail } : {}) }); } } @@ -971,7 +1068,8 @@ async function runCommandSet(cwd: string, runDir: string, commands: string[]): P return { status: results.every((entry) => entry.status === "passed") ? "passed" : "failed", requestedCommands: commands, - results + results, + blockerCategories: uniqueBlockerCategories(results.map((result) => result.blockerCategory)) }; } @@ -1031,7 +1129,13 @@ async function runValidationSpecialist(options: { stderrPath, config: options.config }); - const finalBody = await fs.readFile(finalPath, "utf8"); + const finalBody = await readCodexFinalOutput({ + context: `Validation specialist ${options.specialist.name}`, + finalPath, + stdoutPath, + stderrPath, + result + }); await fs.writeFile(artifactPath, finalBody, "utf8"); return { execution: { @@ -1064,25 +1168,53 @@ function renderCoverageGapsMarkdown(plan: DeliverValidationPlan, localValidation return [ "# Coverage Gaps", "", + `Outcome category: ${plan.outcomeCategory}`, + "", ...(plan.coverage.gaps.length > 0 ? plan.coverage.gaps.map((gap) => `- ${gap}`) : ["- none recorded"]), "", - `Local validation status: ${localValidationRecord.status}` + `Local validation status: ${localValidationRecord.status}`, + ...(localValidationRecord.blockerCategories?.length ? [`Local blockers: ${localValidationRecord.blockerCategories.join(", ")}`] : []) ].join("\n") + "\n"; } +function deriveValidationOutcomeCategory( + plan: Pick<DeliverValidationPlan, "status" | "coverage" | "localValidation" | "unsupported">, + localValidationRecord: DeliverValidationLocalRecord +): DeliverValidationPlan["outcomeCategory"] { + if (localValidationRecord.status === "failed" || (localValidationRecord.blockerCategories?.length ?? 0) > 0) { + return "blocked-by-validation"; + } + if (plan.status === "ready") { + return "ready"; + } + if (plan.status === "blocked") { + return "blocked-by-validation"; + } + return "partial"; +} + function buildCoverageSummary(plan: DeliverValidationPlan, localValidationRecord: DeliverValidationLocalRecord): ValidationCoverageSummary { return { status: plan.status, + outcomeCategory: plan.outcomeCategory, confidence: plan.coverage.confidence, - summary: plan.coverage.summary, + summary: + plan.outcomeCategory === "blocked-by-validation" + ? `Validation commands or validation-specific blockers prevented a ready result. ${plan.coverage.summary}` + : plan.coverage.summary, signals: [ ...plan.coverage.signals, + `${plan.layers.filter((layer) => layer.selected).length} validation layer(s) selected`, + `${plan.localValidation.commands.length} local validation command(s) planned`, + `${plan.ciValidation.jobs.length} CI validation job(s) planned`, ...(localValidationRecord.status === "passed" ? ["selected local validation commands passed"] : []), - ...(localValidationRecord.status === "failed" ? ["one or more selected local validation commands failed"] : []) + ...(localValidationRecord.status === "failed" ? ["one or more selected local validation commands failed"] : []), + ...(localValidationRecord.blockerCategories?.map((blocker) => `local validation blocker: ${blocker}`) ?? []) ], gaps: [ ...plan.coverage.gaps, ...(localValidationRecord.status === "failed" ? ["Local validation command execution failed."] : []), + ...(localValidationRecord.blockerCategories?.map((blocker) => `Local validation blocked by ${blocker}.`) ?? []), ...plan.unsupported ], localValidationStatus: localValidationRecord.status @@ -1107,6 +1239,11 @@ export async function runDeliverValidationExecution(options: DeliverValidationEx const repoProfile = await profileValidationRepository(options.cwd); const toolResearch = buildValidationToolResearch(repoProfile); + const capabilityRecord = buildValidationCapabilityRecord({ + config: options.config, + repoProfile, + toolResearch + }); const selectedSpecialists = selectValidationSpecialists(repoProfile, options.input); const initialPlan = buildInitialValidationPlan(repoProfile, toolResearch, options.buildVerificationRecord, selectedSpecialists); @@ -1165,7 +1302,13 @@ export async function runDeliverValidationExecution(options: DeliverValidationEx config: options.config }); - const finalBody = await fs.readFile(options.paths.finalPath, "utf8"); + const finalBody = await readCodexFinalOutput({ + context: "Validation lead", + finalPath: options.paths.finalPath, + stdoutPath: options.paths.stdoutPath, + stderrPath: options.paths.stderrPath, + result + }); const validationPlan = parseJson<DeliverValidationPlan>(finalBody, "Validation lead"); const acceptedByName = new Map(validationPlan.selectedSpecialists.map((entry) => [entry.name, entry])); for (let index = 0; index < specialistExecutions.length; index += 1) { @@ -1196,6 +1339,7 @@ export async function runDeliverValidationExecution(options: DeliverValidationEx const normalizedPlan: DeliverValidationPlan = { ...validationPlan, status: finalizeValidationPlanStatus(validationPlan, localValidationRecord), + outcomeCategory: deriveValidationOutcomeCategory(validationPlan, localValidationRecord), selectedSpecialists: validationPlan.selectedSpecialists.map((entry) => ({ ...entry, disposition: acceptedByName.get(entry.name)?.disposition ?? entry.disposition @@ -1204,12 +1348,31 @@ export async function runDeliverValidationExecution(options: DeliverValidationEx ...validationPlan.coverage, gaps: [ ...validationPlan.coverage.gaps, - ...(localValidationRecord.status === "failed" ? ["One or more selected validation commands failed."] : []) + ...(localValidationRecord.status === "failed" ? ["One or more selected validation commands failed."] : []), + ...(localValidationRecord.blockerCategories?.map((blocker) => `Validation blocked by ${blocker}.`) ?? []) ] } }; + const selectedToolNames = toolResearch.candidates + .filter((candidate) => candidate.selected) + .map((candidate) => candidate.tool); + const observedCapabilities = inferUsedValidationCapabilities({ + localValidationRecord, + validationPlan: normalizedPlan, + availableCapabilities: capabilityRecord.available, + selectedToolNames + }); + const capabilityArtifact: CapabilityUsageRecord = { + ...capabilityRecord, + used: observedCapabilities, + notes: [ + ...(capabilityRecord.notes ?? []), + "used capabilities are derived from executed local validation commands and observed CI validation job coverage." + ] + }; const coverageSummary = buildCoverageSummary(normalizedPlan, localValidationRecord); + await writeJson(path.join(options.paths.stageDir, "artifacts", "capabilities.json"), capabilityArtifact); await writeJson(options.paths.validationPlanPath, normalizedPlan); await fs.writeFile(options.paths.testPyramidPath, normalizedPlan.pyramidMarkdown, "utf8"); await writeJson(options.paths.coverageSummaryPath, coverageSummary); diff --git a/test/build.test.ts b/test/build.test.ts index 918a3d8..04164ca 100644 --- a/test/build.test.ts +++ b/test/build.test.ts @@ -21,6 +21,7 @@ async function initGitRepo(repoDir: string): Promise<string> { await runGit(repoDir, ["init", "-b", "main"]); await runGit(repoDir, ["config", "user.name", "cstack test"]); await runGit(repoDir, ["config", "user.email", "cstack-test@example.com"]); + await runGit(repoDir, ["config", "commit.gpgSign", "false"]); await runGit(repoDir, ["remote", "add", "origin", remoteDir]); await runGit(repoDir, ["add", "."]); await runGit(repoDir, ["commit", "-m", "fixture"]); @@ -62,6 +63,53 @@ async function seedSpecRun(repoDir: string): Promise<string> { return runId; } +async function seedInitiativeSpecRun(repoDir: string): Promise<string> { + const runId = "2026-03-14T10-30-00-spec-initiative-batching"; + const runDir = path.join(repoDir, ".cstack", "runs", runId); + await fs.mkdir(path.join(runDir, "artifacts"), { recursive: true }); + + const run: RunRecord = { + id: runId, + workflow: "spec", + createdAt: "2026-03-14T10:30:00.000Z", + updatedAt: "2026-03-14T10:30:10.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(runDir, "prompt.md"), + finalPath: path.join(runDir, "final.md"), + contextPath: path.join(runDir, "context.md"), + stdoutPath: path.join(runDir, "stdout.log"), + stderrPath: path.join(runDir, "stderr.log"), + configSources: [], + summary: "Implement initiative-queueing", + inputs: { + userPrompt: "Implement initiative-queueing", + initiativeId: "initiative-ops", + initiativeTitle: "Quarterly initiative" + } + }; + + await fs.writeFile(path.join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "final.md"), "# Spec\n\nImplement initiative-queueing.\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "spec.md"), "# Spec\n\nImplement initiative-queueing.\n", "utf8"); + + return runId; +} + +async function rewriteBuildVerificationCommands(repoDir: string, commands: string[]): Promise<void> { + const configPath = path.join(repoDir, ".cstack", "config.toml"); + const body = await fs.readFile(configPath, "utf8"); + const commandList = commands.map((command) => JSON.stringify(command)).join(", "); + await fs.writeFile( + configPath, + body.replace(/verificationCommands = \[[^\n]+\]/, `verificationCommands = [${commandList}]`), + "utf8" + ); +} + describe("runBuild", () => { let repoDir: string; let remoteDir: string; @@ -106,7 +154,9 @@ describe("runBuild", () => { delete process.env.CSTACK_FORCE_CLONE_FALLBACK; delete process.env.FAKE_CODEX_EARLY_EXIT_BUILD; await fs.rm(repoDir, { recursive: true, force: true }); - await fs.rm(remoteDir, { recursive: true, force: true }); + if (remoteDir) { + await fs.rm(remoteDir, { recursive: true, force: true }); + } }, 60_000); it("creates a build run with session and verification artifacts", async () => { @@ -184,6 +234,28 @@ describe("runBuild", () => { expect(promptBody).toContain("Implement the queued billing retry cleanup"); }, 60_000); + it("inherits and overrides initiative metadata", async () => { + const upstreamRunId = await seedInitiativeSpecRun(repoDir); + + const inheritedRunId = await runBuild(repoDir, ["--from-run", upstreamRunId]); + const inheritedRun = await readRun(repoDir, inheritedRunId); + expect(inheritedRun.inputs.initiativeId).toBe("initiative-ops"); + expect(inheritedRun.inputs.initiativeTitle).toBe("Quarterly initiative"); + + const overrideRunId = await runBuild(repoDir, [ + "--from-run", + upstreamRunId, + "--initiative", + "initiative-override", + "--initiative-title", + "Override initiative" + ]); + const overrideRun = await readRun(repoDir, overrideRunId); + expect(overrideRun.inputs.initiativeId).toBe("initiative-override"); + expect(overrideRun.inputs.initiativeTitle).toBe("Override initiative"); + expect(inheritedRun.id).not.toBe(overrideRun.id); + }, 60_000); + it("ignores untracked .cstack run artifacts when enforcing a clean worktree", async () => { const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); @@ -280,4 +352,77 @@ describe("runBuild", () => { expect(diagnosis.recommendedActions.join("\n")).toContain("Inspect stderr"); expect(recoverySummary).toContain("codex build attempt 2"); }); + + it("classifies missing host tools during verification", async () => { + await rewriteBuildVerificationCommands(repoDir, ["missing-build-tool --version"]); + + await runBuild(repoDir, ["--exec", "Implement the queued billing retry cleanup"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const diagnosis = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "failure-diagnosis.json"), "utf8")) as { + blockerCategory?: string; + }; + const verification = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "verification.json"), "utf8")) as { + blockerCategories?: string[]; + results: Array<{ blockerCategory?: string }>; + }; + + expect(run.status).toBe("completed"); + expect(diagnosis.blockerCategory).toBe("host-tool-missing"); + expect(verification.blockerCategories).toContain("host-tool-missing"); + expect(verification.results[0]?.blockerCategory).toBe("host-tool-missing"); + }); + + it("classifies registry outages during verification", async () => { + await rewriteBuildVerificationCommands(repoDir, [ + "node -e \"process.stderr.write('npm ERR! request to https://registry.npmjs.org failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org\\n'); process.exit(1)\"" + ]); + + await runBuild(repoDir, ["--exec", "Implement the queued billing retry cleanup"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const diagnosis = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "failure-diagnosis.json"), "utf8")) as { + blockerCategory?: string; + }; + + expect(diagnosis.blockerCategory).toBe("registry-unreachable"); + }); + + it("classifies toolchain mismatches during verification", async () => { + await rewriteBuildVerificationCommands(repoDir, [ + "node -e \"process.stderr.write('java.lang.UnsupportedClassVersionError: class file has wrong version 65.0\\n'); process.exit(1)\"" + ]); + + await runBuild(repoDir, ["--exec", "Implement the queued billing retry cleanup"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const diagnosis = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "failure-diagnosis.json"), "utf8")) as { + blockerCategory?: string; + }; + + expect(diagnosis.blockerCategory).toBe("toolchain-mismatch"); + }); + + it("classifies repo test failures during verification", async () => { + await rewriteBuildVerificationCommands(repoDir, [ + "node -e \"process.stderr.write('AssertionError: expected true to be false\\n'); process.exit(1)\" # test" + ]); + + await runBuild(repoDir, ["--exec", "Implement the queued billing retry cleanup"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const diagnosis = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "failure-diagnosis.json"), "utf8")) as { + blockerCategory?: string; + }; + + expect(diagnosis.blockerCategory).toBe("repo-test-failure"); + }); }); diff --git a/test/codex.test.ts b/test/codex.test.ts index 9c75a67..bc562ba 100644 --- a/test/codex.test.ts +++ b/test/codex.test.ts @@ -30,6 +30,10 @@ class FakeWritable extends EventEmitter { afterEach(() => { delete process.env.FAKE_CODEX_STALL_AFTER_OUTPUT_MS; delete process.env.CSTACK_CODEX_COMPLETION_STALL_MS; + delete process.env.FAKE_CODEX_HANG_AFTER_SESSION_MS; + delete process.env.FAKE_CODEX_ACTIVITY_AFTER_SESSION; + delete process.env.FAKE_CODEX_SKIP_FINAL_WRITE; + delete process.env.FAKE_CODEX_EXIT_CODE; }); describe("writePromptToChildStdin", () => { @@ -60,6 +64,10 @@ describe("summarizeActivityLine", () => { it("summarizes meaningful human-readable activity", () => { expect(summarizeActivityLine("reading repo context")).toBe("reading repo context"); expect(summarizeActivityLine('exec /bin/bash -lc "pwd" in /tmp succeeded in 12ms:')).toBe('Tool succeeded: "pwd"'); + expect(summarizeActivityLine("I’m checking the local repository.")).toBe("I’m checking the local repository."); + expect(summarizeActivityLine('/bin/zsh -lc "sed -n \'1,40p\' README.md" in /tmp/repo')).toBe( + 'Tool started: "sed -n \'1,40p\' README.md"' + ); }); it("drops code-like stderr noise", () => { @@ -115,4 +123,203 @@ describe("runCodexExec", () => { await fs.rm(cwd, { recursive: true, force: true }); } }, 15_000); + + it("fails stale discover children that never progress beyond session setup", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cstack-codex-stale-session-")); + const fakeCodexPath = path.resolve("test/fixtures/fake-codex.mjs"); + chmodSync(fakeCodexPath, 0o755); + const finalPath = path.join(cwd, "final.md"); + const eventsPath = path.join(cwd, "events.jsonl"); + const stdoutPath = path.join(cwd, "stdout.log"); + const stderrPath = path.join(cwd, "stderr.log"); + const config: CstackConfig = { + codex: { + command: fakeCodexPath, + sandbox: "workspace-write" + }, + workflows: { + spec: {}, + discover: {}, + build: {}, + review: {}, + ship: {}, + deliver: {} + } + }; + + process.env.FAKE_CODEX_HANG_AFTER_SESSION_MS = "4000"; + try { + const result = await runCodexExec({ + cwd, + workflow: "discover", + runId: "discover-stale-session", + prompt: "You are the `Research Lead` for a bounded `cstack discover` run.", + finalPath, + eventsPath, + stdoutPath, + stderrPath, + config, + silentProgress: true, + timeoutSeconds: 30, + staleSessionTimeoutSeconds: 1 + }); + + expect(result.code).toBe(124); + expect(result.stalled).toBe(true); + expect(result.stallReason).toContain("Stalled after session setup"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }, 15_000); + + it("does not mark a run stalled when the session chunk already includes meaningful activity", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cstack-codex-session-activity-")); + const fakeCodexPath = path.resolve("test/fixtures/fake-codex.mjs"); + chmodSync(fakeCodexPath, 0o755); + const finalPath = path.join(cwd, "final.md"); + const eventsPath = path.join(cwd, "events.jsonl"); + const stdoutPath = path.join(cwd, "stdout.log"); + const stderrPath = path.join(cwd, "stderr.log"); + const config: CstackConfig = { + codex: { + command: fakeCodexPath, + sandbox: "workspace-write" + }, + workflows: { + spec: {}, + discover: {}, + build: {}, + review: {}, + ship: {}, + deliver: {} + } + }; + + process.env.FAKE_CODEX_ACTIVITY_AFTER_SESSION = "1"; + process.env.FAKE_CODEX_HANG_AFTER_SESSION_MS = "1500"; + try { + const result = await runCodexExec({ + cwd, + workflow: "discover", + runId: "discover-session-activity", + prompt: "You are the `Research Lead` for a bounded `cstack discover` run.", + finalPath, + eventsPath, + stdoutPath, + stderrPath, + config, + silentProgress: true, + timeoutSeconds: 10, + staleSessionTimeoutSeconds: 1 + }); + + expect(result.code).toBe(0); + expect(result.stalled).not.toBe(true); + expect(result.lastActivity).not.toContain("Stalled after session setup"); + await expect(fs.readFile(finalPath, "utf8")).resolves.toContain("Fake discover synthesis."); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }, 15_000); + + it("fails when activity appears after session but then stalls without further meaningful output", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cstack-codex-no-progress-")); + const fakeCodexPath = path.resolve("test/fixtures/fake-codex.mjs"); + chmodSync(fakeCodexPath, 0o755); + const finalPath = path.join(cwd, "final.md"); + const eventsPath = path.join(cwd, "events.jsonl"); + const stdoutPath = path.join(cwd, "stdout.log"); + const stderrPath = path.join(cwd, "stderr.log"); + const config: CstackConfig = { + codex: { + command: fakeCodexPath, + sandbox: "workspace-write" + }, + workflows: { + spec: {}, + discover: {}, + build: {}, + review: {}, + ship: {}, + deliver: {} + } + }; + + process.env.FAKE_CODEX_ACTIVITY_AFTER_SESSION = "1"; + process.env.FAKE_CODEX_HANG_AFTER_SESSION_MS = "2500"; + try { + const result = await runCodexExec({ + cwd, + workflow: "build", + runId: "build-no-progress", + prompt: "Implement requested change.", + finalPath, + eventsPath, + stdoutPath, + stderrPath, + config, + silentProgress: true, + timeoutSeconds: 10, + noProgressTimeoutSeconds: 1 + }); + + expect(result.code).toBe(124); + expect(result.stalled).toBe(true); + expect(result.stallReason).toContain("No meaningful activity"); + } finally { + await fs.rm(cwd, { recursive: true, force: true }); + } + }, 15_000); + + it("synthesizes a fallback final artifact when Codex exits without writing one", async () => { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "cstack-codex-missing-final-")); + const fakeCodexPath = path.resolve("test/fixtures/fake-codex.mjs"); + chmodSync(fakeCodexPath, 0o755); + const finalPath = path.join(cwd, "final.md"); + const eventsPath = path.join(cwd, "events.jsonl"); + const stdoutPath = path.join(cwd, "stdout.log"); + const stderrPath = path.join(cwd, "stderr.log"); + const config: CstackConfig = { + codex: { + command: fakeCodexPath, + sandbox: "workspace-write" + }, + workflows: { + spec: {}, + discover: {}, + build: {}, + review: {}, + ship: {}, + deliver: {} + } + }; + + process.env.FAKE_CODEX_PRINT_BODY = "1"; + process.env.FAKE_CODEX_SKIP_FINAL_WRITE = "1"; + process.env.FAKE_CODEX_EXIT_CODE = "1"; + + try { + const result = await runCodexExec({ + cwd, + workflow: "discover", + runId: "discover-missing-final", + prompt: "You are the `Research Lead` for a bounded `cstack discover` run.", + finalPath, + eventsPath, + stdoutPath, + stderrPath, + config, + silentProgress: true, + timeoutSeconds: 10 + }); + + expect(result.code).toBe(1); + expect(result.synthesizedFinalArtifact).toBe(true); + expect(result.synthesizedFinalReason).toContain("synthesized a fallback artifact"); + await expect(fs.readFile(finalPath, "utf8")).resolves.toContain("Fake discover synthesis."); + } finally { + delete process.env.FAKE_CODEX_PRINT_BODY; + await fs.rm(cwd, { recursive: true, force: true }); + } + }, 15_000); }); diff --git a/test/deliver.test.ts b/test/deliver.test.ts index ded155d..6cafa06 100644 --- a/test/deliver.test.ts +++ b/test/deliver.test.ts @@ -46,6 +46,42 @@ async function seedSpecRun(repoDir: string): Promise<string> { return runId; } +async function seedInitiativeSpecRun(repoDir: string): Promise<string> { + const runId = "2026-03-14T10-30-00-spec-initiative-review"; + const runDir = path.join(repoDir, ".cstack", "runs", runId); + await fs.mkdir(path.join(runDir, "artifacts"), { recursive: true }); + + const run: RunRecord = { + id: runId, + workflow: "spec", + createdAt: "2026-03-14T10:30:00.000Z", + updatedAt: "2026-03-14T10:30:10.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(runDir, "prompt.md"), + finalPath: path.join(runDir, "final.md"), + contextPath: path.join(runDir, "context.md"), + stdoutPath: path.join(runDir, "stdout.log"), + stderrPath: path.join(runDir, "stderr.log"), + configSources: [], + summary: "Implement initiative release hardening", + inputs: { + userPrompt: "Implement initiative release hardening", + initiativeId: "initiative-deliver", + initiativeTitle: "Release resilience" + } + }; + + await fs.writeFile(path.join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "final.md"), "# Spec\n\nImplement initiative release hardening.\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "spec.md"), "# Spec\n\nImplement initiative release hardening.\n", "utf8"); + + return runId; +} + async function initGitRepo(repoDir: string): Promise<{ remoteDir: string }> { const remoteDir = await fs.mkdtemp(path.join(os.tmpdir(), "cstack-deliver-remote-")); await execFileAsync("git", ["init", "--bare", remoteDir]); @@ -133,6 +169,11 @@ describe("runDeliver", () => { afterEach(async () => { delete process.env.FAKE_CODEX_FAIL_BUILD; delete process.env.FAKE_CODEX_DELAY_MS; + delete process.env.FAKE_CODEX_VALIDATION_COMMAND; + delete process.env.FAKE_CODEX_VALIDATION_STATUS; + delete process.env.FAKE_CODEX_NO_FINAL_VALIDATION; + delete process.env.FAKE_CODEX_NO_FINAL_DELIVER_REVIEW; + delete process.env.FAKE_CODEX_NO_FINAL_SHIP; delete process.env.CSTACK_FORCE_CLONE_FALLBACK; await fs.rm(repoDir, { recursive: true, force: true }); await fs.rm(remoteDir, { recursive: true, force: true }); @@ -193,6 +234,7 @@ describe("runDeliver", () => { }; const validationPlan = JSON.parse(await fs.readFile(path.join(runDir, "stages", "validation", "validation-plan.json"), "utf8")) as { status: string; + outcomeCategory: string; ciValidation: { jobs: Array<{ name: string }> }; }; const localValidation = JSON.parse( @@ -222,11 +264,27 @@ describe("runDeliver", () => { overall: { status: string }; issueReferences: number[]; }; + const postShipEvidence = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "post-ship-evidence.json"), "utf8")) as { + status: string; + followUpRequired: boolean; + }; + const postShipEvidenceStage = JSON.parse( + await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "post-ship-evidence.json"), "utf8") + ) as { status: string }; + const followUpLineage = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "follow-up-lineage.json"), "utf8")) as { + status: string; + recommendedDrafts: Array<{ title: string }>; + }; const finalBody = await fs.readFile(run.finalPath, "utf8"); const deliveryReport = await fs.readFile(path.join(runDir, "artifacts", "delivery-report.md"), "utf8"); const mutationArtifact = await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "github-mutation.json"), "utf8"); const checksArtifact = await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "checks.json"), "utf8"); const actionsArtifact = await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "actions.json"), "utf8"); + const postShipSummary = await fs.readFile(path.join(runDir, "artifacts", "post-ship-summary.md"), "utf8"); + const postShipDraft = await fs.readFile(path.join(runDir, "artifacts", "follow-up-draft.md"), "utf8"); + const postShipFollowUp = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "follow-up-lineage.json"), "utf8")) as { + status: string; + }; const remoteHeads = await execFileAsync("git", ["--git-dir", remoteDir, "for-each-ref", "--format=%(refname:short)", "refs/heads"]); const consoleOutput = stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join(""); @@ -243,6 +301,7 @@ describe("runDeliver", () => { expect(lineage.stages.map((stage) => stage.name)).toEqual(["build", "validation", "review", "ship"]); expect(lineage.stages.every((stage) => stage.executed)).toBe(true); expect(validationPlan.status).toBe("ready"); + expect(validationPlan.outcomeCategory).toBe("ready"); expect(localValidation.status).toBe("passed"); expect(validationPlan.ciValidation.jobs.map((job) => job.name)).toContain("validation"); expect(reviewVerdict.status).toBe("ready"); @@ -254,6 +313,11 @@ describe("runDeliver", () => { expect(githubMutation.branch.current).toContain("cstack/"); expect(githubMutation.commit.created).toBe(true); expect(githubMutation.commit.sha).toBeTruthy(); + expect(postShipEvidence.status).toBe("stable"); + expect(postShipEvidence.followUpRequired).toBe(false); + expect(postShipEvidenceStage.status).toBe("stable"); + expect(followUpLineage.status).toBe("none"); + expect(followUpLineage.recommendedDrafts).toHaveLength(0); expect(githubMutation.commit.changedFiles).toContain("codex-generated-change.txt"); expect(githubMutation.commit.changedFiles).not.toContain("src-change.txt"); expect(githubMutation.pullRequest.created).toBe(true); @@ -273,16 +337,312 @@ describe("runDeliver", () => { expect(remoteHeads.stdout).toContain(githubMutation.branch.current); expect(finalBody).toContain("# Deliver Run Summary"); expect(deliveryReport).toContain("# Deliver Run Summary"); + expect(postShipSummary).toContain("Post-Ship Summary"); + expect(postShipEvidence.status).toBe("stable"); + expect(postShipFollowUp.status).toBe("none"); + expect(postShipDraft).toContain("No follow-up draft is required"); expect(consoleOutput).toContain("Workflow: deliver"); expect(consoleOutput).toContain("Execution checkout: git-worktree @"); - expect(consoleOutput).toContain("Validation: ready"); + expect(consoleOutput).toContain("Validation: ready (ready)"); expect(consoleOutput).toContain("GitHub mutation:"); - expect(consoleOutput).toContain("Review verdict: ready"); + expect(consoleOutput).toContain("Review verdict: ready"); } finally { stdoutSpy.mockRestore(); } }, 15_000); + it("does not mark deliver complete when validation is partial", async () => { + process.env.FAKE_CODEX_VALIDATION_STATUS = "partial"; + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [], + prChecks: [ + { name: "deliver/test", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/20" }, + { name: "deliver/typecheck", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/21" } + ], + actions: [ + { databaseId: 4, workflowName: "Release", status: "completed", conclusion: "success", url: "https://github.com/ganesh47/cstack/actions/runs/4" } + ], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runDeliver(repoDir, ["Deliver a bounded slice with partial validation"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const lineage = JSON.parse(await fs.readFile(path.join(runDir, "stage-lineage.json"), "utf8")) as StageLineage; + const validationPlan = JSON.parse(await fs.readFile(path.join(runDir, "stages", "validation", "validation-plan.json"), "utf8")) as { + status: string; + outcomeCategory: string; + }; + + expect(run.workflow).toBe("deliver"); + expect(run.status).toBe("failed"); + expect(validationPlan.status).toBe("partial"); + expect(validationPlan.outcomeCategory).toBe("partial"); + expect(lineage.stages.find((stage) => stage.name === "validation")?.status).toBe("deferred"); + }, 20_000); + + it("fails closed when the validation lead exits without writing final output", async () => { + process.env.FAKE_CODEX_NO_FINAL_VALIDATION = "1"; + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [], + prChecks: [ + { name: "deliver/test", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/24" }, + { name: "deliver/typecheck", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/25" } + ], + actions: [ + { databaseId: 5, workflowName: "Release", status: "completed", conclusion: "success", url: "https://github.com/ganesh47/cstack/actions/runs/5" } + ], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runDeliver(repoDir, ["Deliver a slice where validation exits without final output"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const validationPlan = JSON.parse(await fs.readFile(path.join(runDir, "stages", "validation", "validation-plan.json"), "utf8")) as { + status: string; + outcomeCategory: string; + summary: string; + }; + const validationFinal = await fs.readFile(path.join(runDir, "stages", "validation", "final.md"), "utf8"); + const deliverySummary = await fs.readFile(run.finalPath, "utf8"); + + expect(run.status).toBe("failed"); + expect(validationPlan.status).toBe("blocked"); + expect(validationPlan.outcomeCategory).toBe("blocked-by-validation"); + expect(validationPlan.summary).toContain("Validation lead did not write final output"); + expect(validationPlan.summary).not.toContain("ENOENT"); + expect(validationFinal).toContain("Validation stage failed"); + expect(deliverySummary).toContain("Validation lead did not write final output"); + expect(deliverySummary).not.toContain("ENOENT"); + }, 20_000); + + it("fails closed when the ship lead exits without writing final output", async () => { + process.env.FAKE_CODEX_NO_FINAL_SHIP = "1"; + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [ + { + number: 654, + title: "Ship fallback issue", + state: "CLOSED", + url: "https://github.com/ganesh47/cstack/issues/654", + closedAt: "2026-03-14T00:00:00.000Z" + } + ], + prChecks: [ + { name: "deliver/test", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/26" }, + { name: "deliver/typecheck", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/27" } + ], + actions: [ + { databaseId: 6, workflowName: "Release", status: "completed", conclusion: "success", url: "https://github.com/ganesh47/cstack/actions/runs/6" } + ], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runDeliver(repoDir, ["Deliver a slice where ship exits without final output for #654"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const shipRecord = JSON.parse(await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "ship-record.json"), "utf8")) as { + readiness: string; + summary: string; + }; + const shipFinal = await fs.readFile(path.join(runDir, "stages", "ship", "final.md"), "utf8"); + const githubMutation = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "github-mutation.json"), "utf8")) as { + summary: string; + blockers: string[]; + }; + const deliverySummary = await fs.readFile(run.finalPath, "utf8"); + + expect(run.status).toBe("failed"); + expect(shipRecord.readiness).toBe("blocked"); + expect(shipRecord.summary).toContain("Ship lead did not write final output"); + expect(shipRecord.summary).not.toContain("ENOENT"); + expect(shipFinal).toContain("Ship stage failed"); + expect(githubMutation.summary).toContain("Ship stage failed"); + expect(githubMutation.summary).not.toContain("ENOENT"); + expect(githubMutation.blockers.join("\n")).toContain("Ship lead did not write final output"); + expect(deliverySummary).toContain("Ship lead did not write final output"); + expect(deliverySummary).not.toContain("ENOENT"); + }, 20_000); + + it("fails closed when the review lead exits without writing final output", async () => { + process.env.FAKE_CODEX_NO_FINAL_DELIVER_REVIEW = "1"; + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [], + prChecks: [ + { name: "deliver/test", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/28" }, + { name: "deliver/typecheck", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/29" } + ], + actions: [ + { databaseId: 7, workflowName: "Release", status: "completed", conclusion: "success", url: "https://github.com/ganesh47/cstack/actions/runs/7" } + ], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runDeliver(repoDir, ["Deliver a slice where review exits without final output"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const verdict = JSON.parse(await fs.readFile(path.join(runDir, "stages", "review", "artifacts", "verdict.json"), "utf8")) as { + status: string; + summary: string; + }; + const reviewFinal = await fs.readFile(path.join(runDir, "stages", "review", "final.md"), "utf8"); + const deliverySummary = await fs.readFile(run.finalPath, "utf8"); + + expect(run.status).toBe("failed"); + expect(verdict.status).toBe("blocked"); + expect(verdict.summary).toContain("Review lead did not write final output"); + expect(verdict.summary).not.toContain("ENOENT"); + expect(reviewFinal).toContain("Review stage failed"); + expect(deliverySummary).toContain("Review lead did not write final output"); + expect(deliverySummary).not.toContain("ENOENT"); + }, 20_000); + + it("classifies registry blockers from local validation commands", async () => { + process.env.FAKE_CODEX_VALIDATION_COMMAND = + "node -e \"process.stderr.write('npm ERR! request to https://registry.npmjs.org failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org\\n'); process.exit(1)\""; + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [ + { + number: 321, + title: "Validation registry issue", + state: "CLOSED", + url: "https://github.com/ganesh47/cstack/issues/321", + closedAt: "2026-03-14T00:00:00.000Z" + } + ], + prChecks: [], + actions: [], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runDeliver(repoDir, ["Implement release hardening for #321"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const localValidation = JSON.parse( + await fs.readFile(path.join(runDir, "stages", "validation", "artifacts", "local-validation.json"), "utf8") + ) as { blockerCategories?: string[] }; + const coverageSummary = JSON.parse( + await fs.readFile(path.join(runDir, "stages", "validation", "artifacts", "coverage-summary.json"), "utf8") + ) as { gaps: string[]; outcomeCategory: string }; + + expect(run.status).toBe("failed"); + expect(localValidation.blockerCategories).toContain("registry-unreachable"); + expect(coverageSummary.outcomeCategory).toBe("blocked-by-validation"); + expect(coverageSummary.gaps.join("\n")).toContain("registry-unreachable"); + }, 20_000); + + it("classifies repo test failures from local validation commands separately from environment blockers", async () => { + process.env.FAKE_CODEX_VALIDATION_COMMAND = + "node -e \"process.stderr.write('AssertionError: expected response status 200\\n'); process.exit(1)\" # test"; + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [ + { + number: 322, + title: "Validation repo failure", + state: "CLOSED", + url: "https://github.com/ganesh47/cstack/issues/322", + closedAt: "2026-03-14T00:00:00.000Z" + } + ], + prChecks: [], + actions: [], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runDeliver(repoDir, ["Implement release hardening for #322"]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const localValidation = JSON.parse( + await fs.readFile(path.join(runDir, "stages", "validation", "artifacts", "local-validation.json"), "utf8") + ) as { blockerCategories?: string[] }; + const validationPlan = JSON.parse( + await fs.readFile(path.join(runDir, "stages", "validation", "validation-plan.json"), "utf8") + ) as { outcomeCategory: string }; + + expect(run.status).toBe("failed"); + expect(localValidation.blockerCategories).toContain("repo-test-failure"); + expect(localValidation.blockerCategories).not.toContain("registry-unreachable"); + expect(validationPlan.outcomeCategory).toBe("blocked-by-validation"); + }, 20_000); + it("creates a release-bearing deliver run when release evidence exists", async () => { const upstreamRunId = await seedSpecRun(repoDir); await writeGitHubFixture({ @@ -380,6 +740,60 @@ describe("runDeliver", () => { expect(promptBody).toContain("review specialists"); }, 15_000); + it("inherits and overrides initiative metadata", async () => { + const upstreamRunId = await seedInitiativeSpecRun(repoDir); + await writeGitHubFixture({ + repoView: { + nameWithOwner: "ganesh47/cstack", + defaultBranchRef: { name: "main" } + }, + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [ + { + number: 789, + title: "Review initiative issue", + state: "CLOSED", + url: "https://github.com/ganesh47/cstack/issues/789", + closedAt: "2026-03-14T00:00:00.000Z" + } + ], + prChecks: [ + { name: "deliver/test", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/40" }, + { name: "deliver/typecheck", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/41" } + ], + actions: [ + { databaseId: 7, workflowName: "Release", status: "completed", conclusion: "success", url: "https://github.com/ganesh47/cstack/actions/runs/7" } + ], + security: { + dependabot: [], + codeScanning: [] + } + }); + + const inheritedRunId = await runDeliver(repoDir, ["--from-run", upstreamRunId, "Run deliver with inherited initiative"]); + const inheritedRun = await readRun(repoDir, inheritedRunId); + expect(inheritedRun.inputs.initiativeId).toBe("initiative-deliver"); + expect(inheritedRun.inputs.initiativeTitle).toBe("Release resilience"); + + const overrideRunId = await runDeliver(repoDir, [ + "--from-run", + upstreamRunId, + "--initiative", + "initiative-deliver-override", + "--initiative-title", + "Override deliver initiative", + "Run deliver with initiative override" + ]); + const overrideRun = await readRun(repoDir, overrideRunId); + + expect(overrideRun.inputs.initiativeId).toBe("initiative-deliver-override"); + expect(overrideRun.inputs.initiativeTitle).toBe("Override deliver initiative"); + expect(inheritedRun.id).not.toBe(overrideRun.id); + }, 20_000); + it("fails deliver when required GitHub security or checks are blocked", async () => { await writeGitHubFixture({ repoView: { @@ -432,6 +846,14 @@ describe("runDeliver", () => { security: { status: string; blockers: string[] }; overall: { status: string; blockers: string[] }; }; + const readinessPolicy = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "readiness-policy.json"), "utf8")) as { + classifiedBlockers: Array<{ category: string; requirement: string }>; + postReadinessSummary: { headline: string; blockers: string[] }; + }; + const deploymentEvidence = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "deployment-evidence.json"), "utf8")) as { + status: string; + blockers: string[]; + }; const githubMutation = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "github-mutation.json"), "utf8")) as { pullRequest: { created: boolean; url?: string }; }; @@ -449,6 +871,10 @@ describe("runDeliver", () => { expect(githubDelivery.overall.blockers.join("\n")).toContain("Dependabot alert"); expect(githubDelivery.overall.blockers.join("\n")).toContain("Required check deliver/test"); expect(shipRecord.unresolved.join("\n")).toContain("Dependabot alert"); + expect(readinessPolicy.classifiedBlockers.map((entry) => entry.category)).toContain("github-delivery"); + expect(readinessPolicy.postReadinessSummary.headline).toContain("blocked"); + expect(readinessPolicy.postReadinessSummary.blockers.join("\n")).toContain("github-delivery:"); + expect(deploymentEvidence.status).toBe("recorded"); expect(securityArtifact).toContain("\"severity\": \"high\""); }, 15_000); @@ -463,12 +889,22 @@ describe("runDeliver", () => { const lineage = JSON.parse(await fs.readFile(path.join(runDir, "stage-lineage.json"), "utf8")) as StageLineage; const validationPlan = JSON.parse(await fs.readFile(path.join(runDir, "stages", "validation", "validation-plan.json"), "utf8")) as { status: string; + outcomeCategory: string; summary: string; }; const reviewVerdict = JSON.parse(await fs.readFile(path.join(runDir, "stages", "review", "artifacts", "verdict.json"), "utf8")) as { status: string; summary: string; }; + const postShipEvidence = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "post-ship-evidence.json"), "utf8")) as { + status: string; + followUpRequired: boolean; + inferredRecommendations: string[]; + }; + const followUpLineage = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "follow-up-lineage.json"), "utf8")) as { + status: string; + recommendedDrafts: Array<{ title: string }>; + }; const shipRecord = JSON.parse(await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "ship-record.json"), "utf8")) as { readiness: string; summary: string; @@ -486,9 +922,15 @@ describe("runDeliver", () => { expect(lineage.stages.find((stage) => stage.name === "review")).toMatchObject({ status: "deferred", executed: false }); expect(lineage.stages.find((stage) => stage.name === "ship")).toMatchObject({ status: "deferred", executed: false }); expect(validationPlan.status).toBe("blocked"); + expect(validationPlan.outcomeCategory).toBe("blocked-by-build"); expect(validationPlan.summary).toContain("Build failed after Codex started work"); expect(reviewVerdict.status).toBe("blocked"); expect(reviewVerdict.summary).toContain("Build failed after Codex started work"); + expect(postShipEvidence.status).toBe("follow-up-required"); + expect(postShipEvidence.followUpRequired).toBe(true); + expect(postShipEvidence.inferredRecommendations.length).toBeGreaterThan(0); + expect(followUpLineage.status).toBe("recommended"); + expect(followUpLineage.recommendedDrafts.length).toBeGreaterThan(0); expect(shipRecord.readiness).toBe("blocked"); expect(shipRecord.summary).toContain("Build failed after Codex started work"); expect(diagnosis.category).toBe("build-script-failure"); diff --git a/test/discover.test.ts b/test/discover.test.ts index 13cf5cb..7eea6b2 100644 --- a/test/discover.test.ts +++ b/test/discover.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { promises as fs } from "node:fs"; import { chmodSync } from "node:fs"; +import { runRerun } from "../src/commands/rerun.js"; import { runDiscover } from "../src/commands/discover.js"; import { listRuns, readRun } from "../src/run.js"; @@ -32,6 +33,10 @@ describe("runDiscover", () => { "[workflows.discover.research]", "enabled = true", "allowWeb = true", + "", + "[workflows.discover.capabilities]", + 'allowed = ["shell", "web", "github"]', + 'defaultRequested = ["shell", "web"]', "" ].join("\n"), "utf8" @@ -51,6 +56,11 @@ describe("runDiscover", () => { }); afterEach(async () => { + delete process.env.FAKE_CODEX_DELAY_MS; + delete process.env.FAKE_CODEX_DISCOVER_DELAY_MS; + delete process.env.FAKE_CODEX_PRINT_BODY; + delete process.env.FAKE_CODEX_SKIP_FINAL_WRITE; + delete process.env.FAKE_CODEX_EXIT_CODE; await fs.rm(repoDir, { recursive: true, force: true }); }); @@ -74,6 +84,13 @@ describe("runDiscover", () => { mode: string; tracks: Array<{ name: string; selected: boolean }>; }; + const capabilities = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "capabilities.json"), "utf8")) as { + allowed: string[]; + requested: string[]; + available: string[]; + used: string[]; + downgraded: Array<{ name: string }>; + }; const repoResult = JSON.parse( await fs.readFile(path.join(stageDir, "delegates", "repo-explorer", "result.json"), "utf8") ) as { track: string; leaderDisposition: string }; @@ -97,6 +114,12 @@ describe("runDiscover", () => { "risk-researcher", "external-researcher" ]); + expect(capabilities.allowed).toEqual(["shell", "web", "github"]); + expect(capabilities.requested).toEqual(["shell", "web"]); + expect(capabilities.available).toEqual(["shell", "web"]); + expect(capabilities.used).toContain("shell"); + expect(capabilities.used).toContain("web"); + expect(capabilities.downgraded).toEqual([]); expect(repoResult.track).toBe("repo-explorer"); expect(repoResult.leaderDisposition).toBe("accepted"); expect(externalSources[0]?.location).toContain("https://example.com/docs"); @@ -106,6 +129,47 @@ describe("runDiscover", () => { } }, 15_000); + it("writes planning issue lineage artifacts for issue-linked discover runs", async () => { + await runDiscover(repoDir, ["--issue", "123", "Map the repo constraints for the next slice."]); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const lineage = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "issue-lineage.json"), "utf8")) as { + planningIssueNumber: number; + currentRun: { runId: string; workflow: string }; + }; + const contextBody = await fs.readFile(run.contextPath, "utf8"); + const researchPlan = JSON.parse(await fs.readFile(path.join(runDir, "stages", "discover", "research-plan.json"), "utf8")) as { + planningIssueNumber?: number; + }; + + expect(run.inputs.planningIssueNumber).toBe(123); + expect(lineage.planningIssueNumber).toBe(123); + expect(lineage.currentRun.runId).toBe(run.id); + expect(lineage.currentRun.workflow).toBe("discover"); + expect(contextBody).toContain("Planning issue: #123"); + expect(researchPlan.planningIssueNumber).toBe(123); + }); + + it("preserves planning issue linkage on discover rerun", async () => { + await runDiscover(repoDir, ["--issue", "123", "Map the repo constraints for the next slice."]); + const initialRuns = await listRuns(repoDir); + const sourceRun = initialRuns.find((entry) => entry.workflow === "discover"); + + const rerunId = await runRerun(repoDir, [sourceRun!.id]); + const rerun = await readRun(repoDir, rerunId); + + expect(rerun.workflow).toBe("discover"); + expect(rerun.inputs.planningIssueNumber).toBe(123); + const rerunLineage = JSON.parse( + await fs.readFile(path.join(path.dirname(rerun.finalPath), "artifacts", "issue-lineage.json"), "utf8") + ) as { + planningIssueNumber: number; + }; + expect(rerunLineage.planningIssueNumber).toBe(123); + }); + it("suppresses discover delegation for a small local prompt", async () => { await runDiscover(repoDir, "Rename one helper."); @@ -125,6 +189,51 @@ describe("runDiscover", () => { await expect(fs.access(path.join(stageDir, "delegates", "repo-explorer", "result.json"))).rejects.toThrow(); }); + it("records downgraded web capability when discover policy disables web research", async () => { + const configPath = path.join(repoDir, ".cstack", "config.toml"); + const existing = await fs.readFile(configPath, "utf8"); + await fs.writeFile( + configPath, + existing.replace('allowWeb = true', 'allowWeb = false'), + "utf8" + ); + + await runDiscover(repoDir, "Map the repo constraints, official API docs, and security risks for the next slice."); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const capabilities = JSON.parse(await fs.readFile(path.join(path.dirname(run.finalPath), "artifacts", "capabilities.json"), "utf8")) as { + requested: string[]; + available: string[]; + downgraded: Array<{ name: string; reason: string }>; + }; + + expect(capabilities.requested).toContain("web"); + expect(capabilities.available).not.toContain("web"); + expect(capabilities.downgraded).toContainEqual({ + name: "web", + reason: "disabled by discover research policy" + }); + }); + + it("suppresses repo-explorer-only delegation for broad prompts and keeps discover single-agent", async () => { + await runDiscover(repoDir, "What are the gaps in the current project and find them and fix them"); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const stageDir = path.join(path.dirname(run.finalPath), "stages", "discover"); + const researchPlan = JSON.parse(await fs.readFile(path.join(stageDir, "research-plan.json"), "utf8")) as { + mode: string; + tracks: Array<{ name: string; selected: boolean }>; + limitations: string[]; + }; + + expect(researchPlan.mode).toBe("single-agent"); + expect(researchPlan.tracks.every((track) => !track.selected)).toBe(true); + expect(researchPlan.limitations.join("\n")).toContain("repo-explorer track qualified"); + await expect(fs.access(path.join(stageDir, "delegates", "repo-explorer", "result.json"))).rejects.toThrow(); + }); + it("fails cleanly when a delegated discover track times out before writing a final artifact", async () => { const configPath = path.join(repoDir, ".cstack", "config.toml"); const existing = await fs.readFile(configPath, "utf8"); @@ -144,4 +253,57 @@ describe("runDiscover", () => { delete process.env.FAKE_CODEX_DELAY_MS; } }, 15_000); + + it("recovers a partial discover artifact from structured stdout when Codex exits non-zero", async () => { + process.env.FAKE_CODEX_PRINT_BODY = "1"; + process.env.FAKE_CODEX_SKIP_FINAL_WRITE = "1"; + process.env.FAKE_CODEX_EXIT_CODE = "1"; + + await runDiscover(repoDir, "Map the repo constraints for the next slice."); + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const runDir = path.dirname(run.finalPath); + const stageDir = path.join(runDir, "stages", "discover"); + const findings = await fs.readFile(path.join(runDir, "artifacts", "findings.md"), "utf8"); + const discoveryReport = await fs.readFile(path.join(stageDir, "artifacts", "discovery-report.md"), "utf8"); + const researchPlan = JSON.parse(await fs.readFile(path.join(stageDir, "research-plan.json"), "utf8")) as { + mode: string; + tracks: Array<{ name: string; selected: boolean }>; + limitations: string[]; + }; + + expect(run.status).toBe("completed"); + expect(run.lastActivity).toContain("partial artifact"); + expect(run.inputs.delegatedTracks).toEqual([]); + expect(findings).toContain("Research Lead synthesis complete."); + expect(discoveryReport).toContain("Research Lead synthesis complete."); + expect(researchPlan.mode).toBe("single-agent"); + expect(researchPlan.tracks.every((track) => !track.selected)).toBe(true); + await expect(fs.access(path.join(stageDir, "delegates", "repo-explorer", "result.json"))).rejects.toThrow(); + }, 15_000); + + it("respects a shared discover budget instead of giving every delegated track the full timeout", async () => { + const configPath = path.join(repoDir, ".cstack", "config.toml"); + const existing = await fs.readFile(configPath, "utf8"); + await fs.writeFile(configPath, `${existing}\n[workflows.discover]\ntimeoutSeconds = 2\n`, "utf8"); + + process.env.FAKE_CODEX_DISCOVER_DELAY_MS = "1200"; + const startedAt = Date.now(); + await expect( + runDiscover(repoDir, "Map the repo constraints, official API docs, and security risks for the next slice.") + ).rejects.toThrow(/code 124/); + const elapsedMs = Date.now() - startedAt; + + const runs = await listRuns(repoDir); + const run = await readRun(repoDir, runs[0]!.id); + const stageDir = path.join(path.dirname(run.finalPath), "stages", "discover"); + const repoResult = JSON.parse( + await fs.readFile(path.join(stageDir, "delegates", "repo-explorer", "result.json"), "utf8") + ) as { notes?: string }; + + expect(elapsedMs).toBeLessThan(5_000); + expect(run.status).toBe("failed"); + expect(repoResult.notes).toContain("did not produce structured findings"); + }, 15_000); }); diff --git a/test/fixtures/fake-codex.mjs b/test/fixtures/fake-codex.mjs index 83395c2..7e02a08 100755 --- a/test/fixtures/fake-codex.mjs +++ b/test/fixtures/fake-codex.mjs @@ -37,11 +37,19 @@ if (prompt.includes("## Build execution contract") && process.env.FAKE_CODEX_EAR } await new Promise((resolve) => process.stderr.write("session id: fake-session-123\n", resolve)); +if (process.env.FAKE_CODEX_ACTIVITY_AFTER_SESSION === "1") { + await new Promise((resolve) => + process.stderr.write("I'm checking the local repository and the referenced planning documents.\n", resolve) + ); + await new Promise((resolve) => + process.stderr.write("exec /bin/zsh -lc pwd in /tmp succeeded in 0ms:\n", resolve) + ); +} +if (process.env.FAKE_CODEX_HANG_AFTER_SESSION_MS) { + await new Promise((resolve) => setTimeout(resolve, Number.parseInt(process.env.FAKE_CODEX_HANG_AFTER_SESSION_MS, 10))); +} await new Promise((resolve) => process.stdout.write("scanning repository context\n", resolve)); await new Promise((resolve) => setTimeout(resolve, 25)); -if (process.env.FAKE_CODEX_DELAY_MS) { - await new Promise((resolve) => setTimeout(resolve, Number.parseInt(process.env.FAKE_CODEX_DELAY_MS, 10))); -} let body; if (prompt.includes("track in a bounded `cstack discover` research run")) { @@ -126,10 +134,20 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { : "Linked context missing." ].join("\n"); } else if (prompt.includes("You are the `Validation Lead` for a bounded `cstack deliver` workflow.")) { + if (process.env.FAKE_CODEX_NO_FINAL_VALIDATION === "1") { + process.stderr.write("synthetic validation failure without final output\n"); + process.exit(1); + } + const validationStatus = process.env.FAKE_CODEX_VALIDATION_STATUS ?? "ready"; + const validationCommand = process.env.FAKE_CODEX_VALIDATION_COMMAND ?? "node -e \"process.stdout.write('deliver verify ok')\""; + const validationGap = validationStatus === "partial" ? ["Validation evidence intentionally missing from this fake fixture to force partial workflow handling."] : []; body = JSON.stringify( { - status: "ready", - summary: "Validation plan completed with bounded local and CI validation.", + status: validationStatus, + summary: + validationStatus === "ready" + ? "Validation plan completed with bounded local and CI validation." + : "Validation evidence is intentionally incomplete for test control.", profileSummary: "Detected a JavaScript/TypeScript repository with GitHub Actions and packaging validation needs.", layers: [ { @@ -138,8 +156,8 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { status: "ready", rationale: "Static checks catch syntax, types, and workflow errors early.", selectedTools: ["actionlint", "zizmor"], - localCommands: ["node -e \"process.stdout.write('deliver verify ok')\""], - ciCommands: ["node -e \"process.stdout.write('deliver verify ok')\""], + localCommands: [validationCommand], + ciCommands: [validationCommand], coverageIntent: ["type and workflow correctness"], notes: [] }, @@ -149,8 +167,8 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { status: "ready", rationale: "Unit checks protect the common regression surface.", selectedTools: ["vitest"], - localCommands: ["node -e \"process.stdout.write('deliver verify ok')\""], - ciCommands: ["node -e \"process.stdout.write('deliver verify ok')\""], + localCommands: [validationCommand], + ciCommands: [validationCommand], coverageIntent: ["behavioral regressions"], notes: [] }, @@ -182,15 +200,15 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { status: "ready", rationale: "Build and packaging smoke should stay in the delivery path.", selectedTools: ["github_actions"], - localCommands: ["node -e \"process.stdout.write('deliver verify ok')\""], - ciCommands: ["node -e \"process.stdout.write('deliver verify ok')\""], + localCommands: [validationCommand], + ciCommands: [validationCommand], coverageIntent: ["packaging confidence"], notes: [] } ], selectedSpecialists: [], localValidation: { - commands: ["node -e \"process.stdout.write('deliver verify ok')\""], + commands: [validationCommand], prerequisites: ["linux-default"], notes: [] }, @@ -201,7 +219,7 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { name: "validation", runner: "ubuntu-latest", purpose: "Run selected validation commands.", - commands: ["node -e \"process.stdout.write('deliver verify ok')\""], + commands: [validationCommand], artifacts: ["test-reports"] } ], @@ -211,7 +229,7 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { confidence: "medium", summary: "Coverage is layered and centered on the highest-signal checks for this fake fixture.", signals: ["build verification carried forward", "validation pyramid created"], - gaps: [] + gaps: validationGap }, recommendedChanges: ["Keep local and CI validation commands aligned."], unsupported: [], @@ -283,6 +301,10 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { 2 ); } else if (prompt.includes("You are the `Review Lead` for a bounded `cstack deliver` workflow.")) { + if (process.env.FAKE_CODEX_NO_FINAL_DELIVER_REVIEW === "1") { + process.stderr.write("synthetic deliver review failure without final output\n"); + process.exit(1); + } body = JSON.stringify( { mode: "readiness", @@ -303,6 +325,10 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { 2 ); } else if (prompt.includes("You are the `Ship Lead` for a bounded `cstack deliver` workflow.")) { + if (process.env.FAKE_CODEX_NO_FINAL_SHIP === "1") { + process.stderr.write("synthetic ship failure without final output\n"); + process.exit(1); + } body = JSON.stringify( { readiness: "ready", @@ -338,6 +364,12 @@ if (prompt.includes("track in a bounded `cstack discover` research run")) { ].join("\n"); } +if (process.env.FAKE_CODEX_DISCOVER_DELAY_MS && prompt.includes("cstack discover")) { + await new Promise((resolve) => setTimeout(resolve, Number.parseInt(process.env.FAKE_CODEX_DISCOVER_DELAY_MS, 10))); +} else if (process.env.FAKE_CODEX_DELAY_MS) { + await new Promise((resolve) => setTimeout(resolve, Number.parseInt(process.env.FAKE_CODEX_DELAY_MS, 10))); +} + const resolvedFinalPath = finalPath ?? prompt.match(/write a concise markdown summary to:\s*(.+)$/im)?.[1]?.trim(); @@ -347,15 +379,21 @@ if (!resolvedFinalPath) { process.exit(2); } -await writeFile(resolvedFinalPath, `${body}\n`, "utf8"); - -if (process.env.FAKE_CODEX_STALL_AFTER_OUTPUT_MS) { +if (process.env.FAKE_CODEX_PRINT_BODY === "1" || process.env.FAKE_CODEX_STALL_AFTER_OUTPUT_MS) { await new Promise((resolve) => process.stdout.write(`${body}\n`, resolve)); } +if (process.env.FAKE_CODEX_SKIP_FINAL_WRITE !== "1") { + await writeFile(resolvedFinalPath, `${body}\n`, "utf8"); +} + await new Promise((resolve) => process.stdout.write("writing final output\n", resolve)); process.stdout.write("completed\n"); if (process.env.FAKE_CODEX_STALL_AFTER_OUTPUT_MS) { await new Promise((resolve) => setTimeout(resolve, Number.parseInt(process.env.FAKE_CODEX_STALL_AFTER_OUTPUT_MS, 10))); } + +if (process.env.FAKE_CODEX_EXIT_CODE) { + process.exit(Number.parseInt(process.env.FAKE_CODEX_EXIT_CODE, 10)); +} diff --git a/test/inspect.test.ts b/test/inspect.test.ts index 69aff16..4f43e5c 100644 --- a/test/inspect.test.ts +++ b/test/inspect.test.ts @@ -952,6 +952,7 @@ async function seedDeliverRun( `${JSON.stringify( { status: blocked ? "partial" : "ready", + outcomeCategory: blocked ? "partial" : "ready", summary: "Validation planning completed with local and CI parity guidance.", profileSummary: "CLI plus GitHub workflow validation.", layers: [ @@ -1010,6 +1011,7 @@ async function seedDeliverRun( `${JSON.stringify( { status: blocked ? "partial" : "ready", + outcomeCategory: blocked ? "partial" : "ready", confidence: "medium", summary: "Coverage is layered.", signals: ["static checks planned"], @@ -1069,6 +1071,64 @@ async function seedDeliverRun( )}\n`, "utf8" ); + await fs.writeFile( + path.join(runDir, "stages", "ship", "artifacts", "post-ship-evidence.json"), + `${JSON.stringify( + { + status: blocked ? "follow-up-required" : "stable", + summary: blocked + ? "Post-ship follow-up is required based on the recorded ship and GitHub delivery blockers." + : "Post-ship evidence is stable based on the recorded ship and GitHub delivery artifacts.", + observedAt: "2026-03-14T12:15:21.000Z", + observedSignals: [ + { + kind: "ship-readiness", + status: readiness === "ready" ? "ready" : "blocked", + summary: blocked ? "Ship artifacts prepared with outstanding blockers." : "Ship artifacts prepared." + }, + { + kind: "github-delivery", + status: blocked ? "blocked" : "ready", + summary: blocked ? "GitHub delivery still has blockers." : "GitHub delivery is ready." + } + ], + inferredRecommendations: blocked ? ["Create a follow-up to restore the blocked required checks and rerun delivery verification."] : [], + followUpRequired: blocked, + sourceArtifacts: ["artifacts/ship-record.json", "artifacts/github-delivery.json", "artifacts/github-mutation.json"] + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile( + path.join(runDir, "stages", "ship", "artifacts", "follow-up-lineage.json"), + `${JSON.stringify( + { + status: blocked ? "recommended" : "none", + sourceRun: { runId, workflow: "deliver" }, + linkedIssueNumbers: [123], + recommendedDrafts: blocked + ? [{ title: "Follow-up for linked issue #123 (1)", reason: "Create a follow-up to restore the blocked required checks and rerun delivery verification.", priority: "high" }] + : [] + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile(path.join(runDir, "artifacts", "post-ship-summary.md"), "# Post-Ship Summary\n", "utf8"); + await fs.writeFile( + path.join(runDir, "artifacts", "post-ship-evidence.json"), + await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "post-ship-evidence.json"), "utf8"), + "utf8" + ); + await fs.writeFile(path.join(runDir, "artifacts", "follow-up-draft.md"), "# Post-Ship Follow-Up Draft\n", "utf8"); + await fs.writeFile( + path.join(runDir, "artifacts", "follow-up-lineage.json"), + await fs.readFile(path.join(runDir, "stages", "ship", "artifacts", "follow-up-lineage.json"), "utf8"), + "utf8" + ); const githubDelivery = { repository: "ganesh47/cstack", mode, @@ -1274,6 +1334,159 @@ async function seedDeliverRun( await fs.writeFile(path.join(runDir, "stages", "ship", "artifacts", "actions.json"), `${JSON.stringify(githubDelivery.actions, null, 2)}\n`, "utf8"); await fs.writeFile(path.join(runDir, "stages", "ship", "artifacts", "security.json"), `${JSON.stringify(githubDelivery.security, null, 2)}\n`, "utf8"); await fs.writeFile(path.join(runDir, "stages", "ship", "artifacts", "release.json"), `${JSON.stringify(githubDelivery.release, null, 2)}\n`, "utf8"); + await fs.writeFile( + path.join(runDir, "stages", "ship", "artifacts", "readiness-policy.json"), + `${JSON.stringify( + { + mode, + readiness, + generatedAt: "2026-03-14T12:15:20.000Z", + summary: blocked ? "Readiness policy has 2 unmet requirements." : "Readiness policy requirements are satisfied.", + blockers: blocked ? ["github-delivery: 2 blockers remain."] : [], + requirements: [ + { + name: "ship-readiness", + required: true, + status: blocked ? "blocked" : "satisfied", + summary: blocked ? "Ship artifacts prepared with outstanding blockers." : "Ship artifacts prepared.", + evidence: [] + }, + { + name: "github-delivery", + required: true, + status: blocked ? "blocked" : "satisfied", + summary: blocked ? "2 blockers remain." : "All required GitHub gates passed.", + evidence: blocked ? ["Required check deliver/test is failing.", "Dependabot alert #7 is open at severity high."] : [] + } + ], + classifiedBlockers: blocked + ? [ + { + category: "github-delivery", + requirement: "github-delivery", + status: "blocked", + summary: "2 blockers remain.", + evidence: ["Required check deliver/test is failing.", "Dependabot alert #7 is open at severity high."] + } + ] + : [], + postReadinessSummary: { + status: readiness, + headline: blocked ? "Final delivery is blocked by 1 readiness category." : "Final delivery readiness is satisfied.", + highlights: [`Ship readiness: ${readiness}`], + blockers: blocked ? ["github-delivery: 2 blockers remain."] : [], + nextActions: blocked ? ["Resolve the blocked GitHub gates before rerunning deliver."] : [] + } + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile( + path.join(runDir, "stages", "ship", "artifacts", "deployment-evidence.json"), + `${JSON.stringify( + { + mode, + generatedAt: "2026-03-14T12:15:20.000Z", + summary: blocked ? "Recorded 3 deployment-adjacent evidence references." : "Recorded 4 deployment-adjacent evidence references.", + blockers: [], + references: [ + { kind: "pull-request", label: "PR #42", status: blocked ? "blocked" : "ready", url: "https://example.com/pr/42" }, + { kind: "check", label: "deliver/test", status: blocked ? "fail" : "pass" }, + { kind: "action", label: "Release", status: "success", url: "https://example.com/actions/1" } + ], + status: "recorded" + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile( + path.join(runDir, "artifacts", "post-ship-summary.md"), + `${blocked ? "# Post-ship follow-up required\n" : "# Post-ship stable\n"}` + + `\n- status: ${blocked ? "follow-up-required" : "stable"}\n- summary: synthetic post-ship status\n`, + "utf8" + ); + await fs.writeFile( + path.join(runDir, "artifacts", "post-ship-evidence.json"), + `${JSON.stringify( + { + status: blocked || buildFailure ? "follow-up-required" : "stable", + summary: "Synthetic post-ship evidence for inspect fixture.", + observedAt: "2026-03-14T12:15:30.000Z", + observedSignals: [ + { + kind: "ship-readiness", + status: blocked ? "blocked" : "ready", + summary: "Synthetic ship readiness signal." + }, + { + kind: "github-delivery", + status: blocked || buildFailure ? "blocked" : "ready", + summary: "Synthetic github delivery signal." + }, + { + kind: "issues", + status: blocked ? "blocked" : "ready", + summary: "Synthetic issue signal." + }, + { + kind: "checks", + status: blocked || buildFailure ? "blocked" : "ready", + summary: "Synthetic check signal." + }, + { + kind: "actions", + status: "ready", + summary: "Synthetic actions signal." + }, + { + kind: "release", + status: mode === "release" ? "ready" : "not-applicable", + summary: "Synthetic release signal." + }, + { + kind: "security", + status: blocked || buildFailure ? "blocked" : "ready", + summary: "Synthetic security signal." + } + ], + inferredRecommendations: blocked || buildFailure ? ["Re-run checks and blockers before ship is stable."] : [], + followUpRequired: blocked || buildFailure, + sourceArtifacts: ["artifacts/ship-record.json", "artifacts/github-delivery.json", "artifacts/github-mutation.json"] + }, + null, + 2 + )}\n`, + "utf8" + ); + await fs.writeFile( + path.join(runDir, "artifacts", "follow-up-draft.md"), + blocked || buildFailure ? "# Follow-up draft\n\n- medium: re-run blocked checks\n" : "# Follow-up draft\n\nNo follow-up required.\n", + "utf8" + ); + await fs.writeFile( + path.join(runDir, "artifacts", "follow-up-lineage.json"), + `${JSON.stringify( + { + status: blocked || buildFailure ? "recommended" : "none", + sourceRun: { runId, workflow: "deliver" }, + linkedIssueNumbers: githubDelivery.issueReferences, + recommendedDrafts: blocked || buildFailure + ? [{ + title: `Follow-up for linked issue #${githubDelivery.issueReferences[0] ?? 0} (1)`, + reason: "Rerun delivery after blockers clear.", + priority: "high" + }] + : [] + }, + null, + 2 + )}\n`, + "utf8" + ); await fs.writeFile(path.join(runDir, "stderr.log"), buildFailure ? "Interactive codex exited with code 1\n" : "", "utf8"); return runId; @@ -1389,13 +1602,21 @@ describe("inspect", () => { await expect(handleInspectorCommand(repoDir, inspection, "show verification")).resolves.toContain("\"status\": \"passed\""); await expect(handleInspectorCommand(repoDir, inspection, "show validation")).resolves.toContain("Workspace targets: 2"); await expect(handleInspectorCommand(repoDir, inspection, "show validation")).resolves.toContain("\"status\": \"partial\""); + await expect(handleInspectorCommand(repoDir, inspection, "show validation")).resolves.toContain("Outcome category: partial"); await expect(handleInspectorCommand(repoDir, inspection, "show validation")).resolves.toContain("packages/cli: inventory-only"); await expect(handleInspectorCommand(repoDir, inspection, "show pyramid")).resolves.toContain("# Test Pyramid"); await expect(handleInspectorCommand(repoDir, inspection, "show coverage")).resolves.toContain("\"localValidationStatus\": \"passed\""); + await expect(handleInspectorCommand(repoDir, inspection, "show coverage")).resolves.toContain("Outcome category: partial"); await expect(handleInspectorCommand(repoDir, inspection, "show ci-validation")).resolves.toContain("\"runner\": \"ubuntu-latest\""); await expect(handleInspectorCommand(repoDir, inspection, "show tool-research")).resolves.toContain("\"tool\": \"actionlint\""); await expect(handleInspectorCommand(repoDir, inspection, "show review")).resolves.toContain("\"status\": \"changes-requested\""); + await expect(handleInspectorCommand(repoDir, inspection, "show post-ship")).resolves.toContain("Post-ship evidence:"); + await expect(handleInspectorCommand(repoDir, inspection, "show follow-up")).resolves.toContain("Post-ship follow-up:"); await expect(handleInspectorCommand(repoDir, inspection, "show ship")).resolves.toContain("\"readiness\": \"blocked\""); + await expect(handleInspectorCommand(repoDir, inspection, "show readiness")).resolves.toContain("Classified blockers:"); + await expect(handleInspectorCommand(repoDir, inspection, "show readiness")).resolves.toContain("github-delivery: 2 blockers remain."); + await expect(handleInspectorCommand(repoDir, inspection, "show deployment")).resolves.toContain("Deployment evidence:"); + await expect(handleInspectorCommand(repoDir, inspection, "show deployment")).resolves.toContain("pull-request: PR #42"); await expect(handleInspectorCommand(repoDir, inspection, "show mutation")).resolves.toContain("\"current\": \"cstack/billing-cleanup\""); await expect(handleInspectorCommand(repoDir, inspection, "show github")).resolves.toContain("\"status\": \"blocked\""); await expect(handleInspectorCommand(repoDir, inspection, "show checks")).resolves.toContain("GitHub checks gate: blocked"); @@ -1406,9 +1627,10 @@ describe("inspect", () => { await expect(handleInspectorCommand(repoDir, inspection, "show artifact stages/ship/artifacts/checks.json")).resolves.toContain("\"conclusion\": \"fail\""); await expect(handleInspectorCommand(repoDir, inspection, "show artifact stages/ship/artifacts/security.json")).resolves.toContain("\"severity\": \"high\""); await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("ship readiness: blocked"); - await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("validation: partial"); + await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("validation: partial (partial)"); await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("github mutation: Branch pushed and pull request prepared."); await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("github delivery: blocked (checks, security: Required check deliver/test is failing.)"); + await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("post-ship: follow-up-required"); }); it("shows ready GitHub delivery details for ready deliver runs", async () => { @@ -1419,13 +1641,19 @@ describe("inspect", () => { expect(inspection.deliverShipRecord?.readiness).toBe("ready"); expect(inspection.githubMutationRecord?.branch.current).toBe("cstack/billing-cleanup-ready"); expect(inspection.githubDeliveryRecord?.overall.status).toBe("ready"); + await expect(handleInspectorCommand(repoDir, inspection, "show readiness")).resolves.toContain("Final delivery readiness is satisfied."); + await expect(handleInspectorCommand(repoDir, inspection, "show deployment")).resolves.toContain("status: recorded"); await expect(handleInspectorCommand(repoDir, inspection, "show mutation")).resolves.toContain("\"url\": \"https://example.com/pr/42\""); await expect(handleInspectorCommand(repoDir, inspection, "show pr")).resolves.toContain("GitHub pull request gate: ready"); await expect(handleInspectorCommand(repoDir, inspection, "show release")).resolves.toContain("GitHub release gate: ready"); await expect(handleInspectorCommand(repoDir, inspection, "show actions")).resolves.toContain("GitHub actions gate: ready"); + await expect(handleInspectorCommand(repoDir, inspection, "show post-ship")).resolves.toContain("- status: stable"); + await expect(handleInspectorCommand(repoDir, inspection, "show follow-up")).resolves.toContain("No recommended follow-up drafts."); + await expect(handleInspectorCommand(repoDir, inspection, "show artifact artifacts/post-ship-evidence.json")).resolves.toContain("\"status\": \"stable\""); await expect(handleInspectorCommand(repoDir, inspection, "what remains")).resolves.toContain("no deferred or missing work recorded"); await expect(handleInspectorCommand(repoDir, inspection, "show artifact stages/ship/artifacts/release.json")).resolves.toContain("\"tagName\": \"v1.2.3\""); await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("github delivery: ready"); + await expect(handleInspectorCommand(repoDir, inspection, "1")).resolves.toContain("post-ship: stable"); }); it("can launch a mitigation workflow directly from a review inspection", async () => { @@ -1586,4 +1814,186 @@ describe("inspect", () => { await expect(handleInspectorCommand(repoDir, inspection, "show stage build")).resolves.toContain("Linked child run:"); await expect(handleInspectorCommand(repoDir, inspection, "artifacts")).resolves.toContain(`- child build: ${childRunId} (failed)`); }); + + it("renders planning issue artifacts for spec runs", async () => { + const runId = "2026-03-14T11-00-00-spec-issue-linked"; + const runDir = path.join(repoDir, ".cstack", "runs", runId); + await fs.mkdir(path.join(runDir, "artifacts"), { recursive: true }); + + const run: RunRecord = { + id: runId, + workflow: "spec", + createdAt: "2026-03-14T11:00:00.000Z", + updatedAt: "2026-03-14T11:00:20.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(runDir, "prompt.md"), + finalPath: path.join(runDir, "final.md"), + contextPath: path.join(runDir, "context.md"), + eventsPath: path.join(runDir, "events.jsonl"), + stdoutPath: path.join(runDir, "stdout.log"), + stderrPath: path.join(runDir, "stderr.log"), + configSources: [], + summary: "Issue-linked spec", + inputs: { + userPrompt: "Issue-linked spec", + planningIssueNumber: 123 + } + }; + + await fs.writeFile(path.join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "final.md"), "# Final\n\nSpec summary.\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "spec.md"), "# Spec\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "issue-draft.md"), "# Planning Issue Draft: #123\n", "utf8"); + await fs.writeFile( + path.join(runDir, "artifacts", "issue-lineage.json"), + `${JSON.stringify({ + planningIssueNumber: 123, + currentRun: { runId, workflow: "spec" }, + downstreamPullRequests: [], + downstreamReleases: [] + }, null, 2)}\n`, + "utf8" + ); + + const inspection = await loadRunInspection(repoDir, runId); + + await expect(handleInspectorCommand(repoDir, inspection, "summary")).resolves.toContain("planning issue: #123"); + await expect(handleInspectorCommand(repoDir, inspection, "summary")).resolves.toContain("issue draft: artifacts/issue-draft.md"); + await expect(handleInspectorCommand(repoDir, inspection, "show issue")).resolves.toContain("Planning issue: #123"); + await expect(handleInspectorCommand(repoDir, inspection, "show issue")).resolves.toContain("Issue lineage: artifacts/issue-lineage.json"); + }); + + it("renders planning issue artifacts for discover runs", async () => { + const runId = "2026-03-14T11-00-00-discover-issue-linked"; + const runDir = path.join(repoDir, ".cstack", "runs", runId); + await fs.mkdir(path.join(runDir, "artifacts"), { recursive: true }); + + const run: RunRecord = { + id: runId, + workflow: "discover", + createdAt: "2026-03-14T11:00:00.000Z", + updatedAt: "2026-03-14T11:00:20.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(runDir, "prompt.md"), + finalPath: path.join(runDir, "final.md"), + contextPath: path.join(runDir, "context.md"), + eventsPath: path.join(runDir, "events.jsonl"), + stdoutPath: path.join(runDir, "stdout.log"), + stderrPath: path.join(runDir, "stderr.log"), + configSources: [], + summary: "Issue-linked discover", + inputs: { + userPrompt: "Issue-linked discover", + planningIssueNumber: 123 + } + }; + + await fs.writeFile(path.join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "final.md"), "# Final\n\nDiscover summary.\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "findings.md"), "# Findings\n", "utf8"); + await fs.writeFile( + path.join(runDir, "artifacts", "issue-lineage.json"), + `${JSON.stringify({ + planningIssueNumber: 123, + currentRun: { runId, workflow: "discover" }, + downstreamPullRequests: [], + downstreamReleases: [] + }, null, 2)}\n`, + "utf8" + ); + + const inspection = await loadRunInspection(repoDir, runId); + + await expect(handleInspectorCommand(repoDir, inspection, "summary")).resolves.toContain("planning issue: #123"); + await expect(handleInspectorCommand(repoDir, inspection, "summary")).resolves.toContain("issue lineage: artifacts/issue-lineage.json"); + await expect(handleInspectorCommand(repoDir, inspection, "show issue")).resolves.toContain("Planning issue: #123"); + await expect(handleInspectorCommand(repoDir, inspection, "show issue")).resolves.toContain(`Current run: ${runId} (discover)`); + }); + + it("derives initiative summaries from run metadata when no artifact is present", async () => { + const baselineRunId = "2026-03-14T11-00-00-spec-cache-base"; + const baselineRunDir = path.join(repoDir, ".cstack", "runs", baselineRunId); + const baselineRun: RunRecord = { + id: baselineRunId, + workflow: "spec", + createdAt: "2026-03-14T11:00:00.000Z", + updatedAt: "2026-03-14T11:00:05.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(baselineRunDir, "prompt.md"), + finalPath: path.join(baselineRunDir, "final.md"), + contextPath: path.join(baselineRunDir, "context.md"), + stdoutPath: path.join(baselineRunDir, "stdout.log"), + stderrPath: path.join(baselineRunDir, "stderr.log"), + configSources: [], + summary: "Baseline initiative planning", + inputs: { + userPrompt: "Baseline initiative planning", + initiativeId: "initiative-cache", + initiativeTitle: "Cache rollout" + } + }; + const currentRunId = "2026-03-14T11-10-00-spec-cache-follow-up"; + const currentRunDir = path.join(repoDir, ".cstack", "runs", currentRunId); + const currentRun: RunRecord = { + id: currentRunId, + workflow: "spec", + createdAt: "2026-03-14T11:10:00.000Z", + updatedAt: "2026-03-14T11:10:05.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(currentRunDir, "prompt.md"), + finalPath: path.join(currentRunDir, "final.md"), + contextPath: path.join(currentRunDir, "context.md"), + eventsPath: path.join(currentRunDir, "events.jsonl"), + stdoutPath: path.join(currentRunDir, "stdout.log"), + stderrPath: path.join(currentRunDir, "stderr.log"), + configSources: [], + summary: "Follow-up initiative planning", + inputs: { + userPrompt: "Follow-up initiative planning", + initiativeId: "initiative-cache", + initiativeTitle: "Cache rollout" + } + }; + + await fs.mkdir(baselineRunDir, { recursive: true }); + await fs.mkdir(currentRunDir, { recursive: true }); + await fs.writeFile(path.join(baselineRunDir, "run.json"), `${JSON.stringify(baselineRun, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(currentRunDir, "run.json"), `${JSON.stringify(currentRun, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(baselineRunDir, "final.md"), "# Final\n", "utf8"); + await fs.writeFile(path.join(currentRunDir, "final.md"), "# Final\n", "utf8"); + + const inspection = await loadRunInspection(repoDir, currentRunId); + + expect(inspection.initiativeGraph?.initiativeId).toBe("initiative-cache"); + expect(inspection.initiativeGraph?.relatedRuns.some((entry) => entry.runId === baselineRunId)).toBe(true); + await expect(handleInspectorCommand(repoDir, inspection, "summary")).resolves.toContain( + "initiative: initiative-cache (Cache rollout)" + ); + await expect(handleInspectorCommand(repoDir, inspection, "summary")).resolves.toContain( + "initiative graph: derived from run metadata" + ); + await expect(handleInspectorCommand(repoDir, inspection, "show initiative")).resolves.toContain( + "Initiative: initiative-cache (Cache rollout)" + ); + await expect(handleInspectorCommand(repoDir, inspection, "show initiative")).resolves.toContain("Run group:"); + await expect(handleInspectorCommand(repoDir, inspection, "show initiative")).resolves.toContain( + `${baselineRunId} (spec, completed)` + ); + }); }); diff --git a/test/intent.test.ts b/test/intent.test.ts index cce189d..d5fda3e 100644 --- a/test/intent.test.ts +++ b/test/intent.test.ts @@ -17,9 +17,11 @@ async function initGitRepo(repoDir: string): Promise<string> { await execFileAsync("git", ["init", "-b", "main"], { cwd: repoDir }); await execFileAsync("git", ["config", "user.name", "cstack test"], { cwd: repoDir }); await execFileAsync("git", ["config", "user.email", "cstack-test@example.com"], { cwd: repoDir }); + await execFileAsync("git", ["config", "commit.gpgSign", "false"], { cwd: repoDir }); + await execFileAsync("git", ["config", "tag.gpgSign", "false"], { cwd: repoDir }); await execFileAsync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); await execFileAsync("git", ["add", "."], { cwd: repoDir }); - await execFileAsync("git", ["commit", "-m", "fixture"], { cwd: repoDir }); + await execFileAsync("git", ["-c", "commit.gpgSign=false", "commit", "-m", "fixture"], { cwd: repoDir }); await execFileAsync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); return remoteDir; } @@ -75,7 +77,9 @@ describe("intent router", () => { delete process.env.FAKE_CODEX_FAIL_BUILD; delete process.env.FAKE_CODEX_DELAY_MS; await fs.rm(repoDir, { recursive: true, force: true }); - await fs.rm(remoteDir, { recursive: true, force: true }); + if (remoteDir) { + await fs.rm(remoteDir, { recursive: true, force: true }); + } }); it("infers staged execution and specialists from intent", () => { @@ -268,6 +272,34 @@ describe("intent router", () => { expect(executionContext.source.localChangesIgnored).toBe(true); }, 60_000); + it("marks discover failed in stage lineage when the direct discover stage times out", async () => { + const configPath = path.join(repoDir, ".cstack", "config.toml"); + const existingConfig = await fs.readFile(configPath, "utf8"); + await fs.writeFile(configPath, `${existingConfig}\n[workflows.discover]\ntimeoutSeconds = 1\n`, "utf8"); + process.env.FAKE_CODEX_DELAY_MS = "1500"; + + await expect( + runIntent(repoDir, "What are the gaps in this project? Can you work on closing the gaps?", { + entrypoint: "bare", + dryRun: false + }) + ).rejects.toThrow(/code 124|timed out/i); + + const runs = await listRuns(repoDir); + const intentRun = await readRun( + repoDir, + runs.find((entry) => entry.workflow === "intent")!.id + ); + const intentRunDir = path.dirname(intentRun.finalPath); + const lineage = JSON.parse(await fs.readFile(path.join(intentRunDir, "stage-lineage.json"), "utf8")) as StageLineage; + + expect(intentRun.status).toBe("failed"); + expect(intentRun.error).toMatch(/code 124|timed out/i); + expect(lineage.stages.find((stage) => stage.name === "discover")?.status).toBe("failed"); + expect(lineage.stages.find((stage) => stage.name === "discover")?.executed).toBe(true); + expect(lineage.stages.find((stage) => stage.name === "discover")?.notes).toMatch(/code 124|timed out/i); + }, 60_000); + it("surfaces downstream build failure promptly and marks later stages deferred", async () => { process.env.FAKE_CODEX_FAIL_BUILD = "1"; diff --git a/test/prompt.test.ts b/test/prompt.test.ts index df37931..191a9b4 100644 --- a/test/prompt.test.ts +++ b/test/prompt.test.ts @@ -53,6 +53,23 @@ describe("prompt builders", () => { expect(context).toContain("Reference files:"); }); + it("adds explicit narrowing rules for broad gap-remediation spec prompts", async () => { + const { prompt } = await buildSpecPrompt(repoDir, "What are the gaps in the current project and find them and fix them", config); + + expect(prompt).toContain("select exactly one slice to implement first"); + expect(prompt).toContain("one bounded change set"); + expect(prompt).toContain("avoid multi-epic roadmaps"); + }); + + it("records linked planning issue context in spec prompts", async () => { + const { prompt, context } = await buildSpecPrompt(repoDir, "Plan the first slice.", config, { + planningIssueNumber: 123 + }); + + expect(prompt).toContain("GitHub issue: #123"); + expect(context).toContain("Planning issue: #123"); + }); + it("omits missing cstack-specific references from discover prompts", async () => { const plan: DiscoverResearchPlan = { prompt: "Map the repo", @@ -99,7 +116,13 @@ describe("prompt builders", () => { expect(trackPrompt.prompt).not.toContain("docs/specs/cstack-spec-v0.1.md"); expect(trackPrompt.prompt).toContain(path.join(repoDir, "README.md")); + expect(trackPrompt.prompt).toContain("inspect representative files only"); + expect(trackPrompt.prompt).toContain("top 3 gaps or first remediation candidates"); + expect(trackPrompt.prompt).toContain("at most 8 commands"); + expect(trackPrompt.prompt).toContain("\"requestedCapabilities\""); + expect(trackPrompt.prompt).toContain("\"availableCapabilities\""); expect(leadPrompt.prompt).not.toContain("docs/research/gstack-codex-interaction-model.md"); expect(leadPrompt.prompt).toContain(path.join(repoDir, "specs", "001-plan-alignment", "research.md")); + expect(leadPrompt.prompt).toContain("\"topFindings\""); }); }); diff --git a/test/review.test.ts b/test/review.test.ts index c830fb1..c352f36 100644 --- a/test/review.test.ts +++ b/test/review.test.ts @@ -60,6 +60,60 @@ async function seedBuildRun(repoDir: string): Promise<string> { return runId; } +async function seedInitiativeBuildRun(repoDir: string): Promise<string> { + const runId = "2026-03-14T11-30-00-build-initiative-queue"; + const runDir = path.join(repoDir, ".cstack", "runs", runId); + await fs.mkdir(path.join(runDir, "artifacts"), { recursive: true }); + + const run: RunRecord = { + id: runId, + workflow: "build", + createdAt: "2026-03-14T11:30:00.000Z", + updatedAt: "2026-03-14T11:30:10.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(runDir, "prompt.md"), + finalPath: path.join(runDir, "final.md"), + contextPath: path.join(runDir, "context.md"), + stdoutPath: path.join(runDir, "stdout.log"), + stderrPath: path.join(runDir, "stderr.log"), + configSources: [], + summary: "Build initiative cleanup", + inputs: { + userPrompt: "Build initiative cleanup", + initiativeId: "initiative-2026", + initiativeTitle: "Refactor platform APIs" + } + }; + + const verification: BuildVerificationRecord = { + status: "passed", + requestedCommands: ["npm test"], + results: [ + { + command: "npm test", + exitCode: 0, + status: "passed", + durationMs: 25, + stdoutPath: path.join(runDir, "artifacts", "verification", "1.stdout.log"), + stderrPath: path.join(runDir, "artifacts", "verification", "1.stderr.log") + } + ] + }; + + await fs.writeFile(path.join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "final.md"), "# Build Summary\n\nImplemented initiative cleanup.\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "change-summary.md"), "# Build Summary\n\nImplemented initiative cleanup.\n", "utf8"); + await fs.mkdir(path.join(runDir, "artifacts", "verification"), { recursive: true }); + await fs.writeFile(path.join(runDir, "artifacts", "verification.json"), `${JSON.stringify(verification, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "session.json"), `${JSON.stringify({ workflow: "build", requestedMode: "exec", mode: "exec", sessionId: "fake-session-456", codexCommand: ["codex"], observability: { sessionIdObserved: true, transcriptObserved: false, finalArtifactObserved: true }, startedAt: "2026-03-14T11:30:00.000Z", endedAt: "2026-03-14T11:30:10.000Z" }, null, 2)}\n`, "utf8"); + + return runId; +} + describe("runReview", () => { let repoDir: string; @@ -189,4 +243,39 @@ describe("runReview", () => { stdoutSpy.mockRestore(); } }, 60_000); + + it("inherits and overrides initiative metadata", async () => { + const buildRunId = await seedInitiativeBuildRun(repoDir); + + await runReview(repoDir, [ + "--from-run", + buildRunId, + "Review initiative cleanup" + ]); + + const inheritedRun = await readRun( + repoDir, + (await listRuns(repoDir)).filter((entry) => entry.workflow === "review")[0]!.id + ); + expect(inheritedRun.inputs.initiativeId).toBe("initiative-2026"); + expect(inheritedRun.inputs.initiativeTitle).toBe("Refactor platform APIs"); + + await runReview(repoDir, [ + "--from-run", + buildRunId, + "--initiative", + "initiative-review-override", + "--initiative-title", + "Override initiative title", + "Review initiative cleanup" + ]); + + const overrideRun = await readRun( + repoDir, + (await listRuns(repoDir)).filter((entry) => entry.workflow === "review")[0]!.id + ); + expect(overrideRun.inputs.initiativeId).toBe("initiative-review-override"); + expect(overrideRun.inputs.initiativeTitle).toBe("Override initiative title"); + expect(inheritedRun.id).not.toBe(overrideRun.id); + }, 60_000); }); diff --git a/test/runs.test.ts b/test/runs.test.ts index 39213b3..359c0d8 100644 --- a/test/runs.test.ts +++ b/test/runs.test.ts @@ -68,6 +68,30 @@ describe("runRuns", () => { } }); + await writeRun(repoDir, { + id: "2026-03-13T18-20-00-spec-initiative", + workflow: "spec", + createdAt: "2026-03-13T18:20:00.000Z", + updatedAt: "2026-03-13T18:20:05.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(repoDir, ".cstack", "runs", "2026-03-13T18-20-00-spec-initiative", "prompt.md"), + finalPath: path.join(repoDir, ".cstack", "runs", "2026-03-13T18-20-00-spec-initiative", "final.md"), + contextPath: path.join(repoDir, ".cstack", "runs", "2026-03-13T18-20-00-spec-initiative", "context.md"), + stdoutPath: path.join(repoDir, ".cstack", "runs", "2026-03-13T18-20-00-spec-initiative", "stdout.log"), + stderrPath: path.join(repoDir, ".cstack", "runs", "2026-03-13T18-20-00-spec-initiative", "stderr.log"), + configSources: [], + summary: "Group initiative-scoped runs", + inputs: { + userPrompt: "Group initiative-scoped runs", + initiativeId: "initiative-cache", + initiativeTitle: "Cache rollout" + } + }); + await writeRun( repoDir, { @@ -128,6 +152,7 @@ describe("runRuns", () => { expect(output).toContain("audit-review"); expect(output).toContain("Plan a compliance-safe billing migration"); expect(output).toContain("failed"); + expect(output).toContain("initiative-cache (Cache rollout)"); } finally { stdoutSpy.mockRestore(); } @@ -149,6 +174,20 @@ describe("runRuns", () => { } }); + it("filters runs by initiative in json output", async () => { + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + try { + await runRuns(repoDir, ["--initiative", "initiative-cache", "--json"]); + const output = stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join(""); + const entries = JSON.parse(output) as Array<{ id: string; initiativeId?: string }>; + + expect(entries).toHaveLength(1); + expect(entries[0]?.initiativeId).toBe("initiative-cache"); + } finally { + stdoutSpy.mockRestore(); + } + }); + it("falls back to prompt or workflow metadata when run summary is missing", async () => { await writeRun(repoDir, { id: "2026-03-13T17-40-00-build-no-summary", diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index 70cce29..beb858a 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -19,9 +19,11 @@ async function initGitRepo(repoDir: string): Promise<string> { await execFileAsync("git", ["init", "-b", "main"], { cwd: repoDir }); await execFileAsync("git", ["config", "user.name", "cstack test"], { cwd: repoDir }); await execFileAsync("git", ["config", "user.email", "cstack-test@example.com"], { cwd: repoDir }); + await execFileAsync("git", ["config", "commit.gpgSign", "false"], { cwd: repoDir }); + await execFileAsync("git", ["config", "tag.gpgSign", "false"], { cwd: repoDir }); await execFileAsync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); await execFileAsync("git", ["add", "."], { cwd: repoDir }); - await execFileAsync("git", ["commit", "-m", "fixture"], { cwd: repoDir }); + await execFileAsync("git", ["-c", "commit.gpgSign=false", "commit", "-m", "fixture"], { cwd: repoDir }); await execFileAsync("git", ["push", "-u", "origin", "main"], { cwd: repoDir }); return remoteDir; } @@ -124,7 +126,9 @@ describe("session support commands", () => { afterEach(async () => { await fs.rm(repoDir, { recursive: true, force: true }); - await fs.rm(remoteDir, { recursive: true, force: true }); + if (remoteDir) { + await fs.rm(remoteDir, { recursive: true, force: true }); + } }); it("resolves a run id to codex resume", async () => { diff --git a/test/ship.test.ts b/test/ship.test.ts index e1937e4..29c3419 100644 --- a/test/ship.test.ts +++ b/test/ship.test.ts @@ -112,6 +112,55 @@ async function seedReviewRun(repoDir: string, buildRunId: string): Promise<strin return runId; } +async function seedInitiativeReviewRun(repoDir: string, buildRunId: string): Promise<string> { + const runId = "2026-03-14T12-00-00-review-initiative-cleanup"; + const runDir = path.join(repoDir, ".cstack", "runs", runId); + await fs.mkdir(path.join(runDir, "artifacts"), { recursive: true }); + + const run: RunRecord = { + id: runId, + workflow: "review", + createdAt: "2026-03-14T12:00:00.000Z", + updatedAt: "2026-03-14T12:00:10.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(runDir, "prompt.md"), + finalPath: path.join(runDir, "final.md"), + contextPath: path.join(runDir, "context.md"), + stdoutPath: path.join(runDir, "stdout.log"), + stderrPath: path.join(runDir, "stderr.log"), + configSources: [], + summary: "Review initiative cleanup", + inputs: { + userPrompt: "Review initiative cleanup", + linkedRunId: buildRunId, + initiativeId: "initiative-ship", + initiativeTitle: "Platform initiative" + } + }; + + const verdict: DeliverReviewVerdict = { + mode: "readiness", + status: "ready", + summary: "Review passed.", + findings: [], + recommendedActions: [], + acceptedSpecialists: [], + reportMarkdown: "# Review Findings\n\nReview passed.\n" + }; + + await fs.writeFile(path.join(runDir, "run.json"), `${JSON.stringify(run, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "final.md"), "# Review Run Summary\n\nReady.\n", "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "findings.md"), verdict.reportMarkdown, "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "findings.json"), `${JSON.stringify({ findings: [], recommendedActions: [], acceptedSpecialists: [] }, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(runDir, "artifacts", "verdict.json"), `${JSON.stringify(verdict, null, 2)}\n`, "utf8"); + + return runId; +} + describe("runShip", () => { let repoDir: string; let remoteDir: string; @@ -228,6 +277,24 @@ describe("runShip", () => { const shipRecord = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "ship-record.json"), "utf8")) as { readiness: string; }; + const readinessPolicy = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "readiness-policy.json"), "utf8")) as { + summary: string; + requirements: Array<{ name: string; status: string }>; + classifiedBlockers: Array<{ category: string }>; + postReadinessSummary: { headline: string; blockers: string[] }; + }; + const deploymentEvidence = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "deployment-evidence.json"), "utf8")) as { + status: string; + references: Array<{ kind: string; label: string }>; + }; + const postShipEvidence = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "post-ship-evidence.json"), "utf8")) as { + status: string; + followUpRequired: boolean; + }; + const followUpLineage = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "follow-up-lineage.json"), "utf8")) as { + status: string; + recommendedDrafts: Array<{ title: string }>; + }; const githubMutation = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "github-mutation.json"), "utf8")) as { branch: { current: string }; pullRequest: { created: boolean }; @@ -239,12 +306,86 @@ describe("runShip", () => { expect(run.status).toBe("completed"); expect(run.inputs.linkedRunId).toBe(reviewRunId); expect(shipRecord.readiness).toBe("ready"); + expect(readinessPolicy.summary).toContain("satisfied"); + expect(readinessPolicy.requirements.find((entry) => entry.name === "github-delivery")?.status).toBe("satisfied"); + expect(readinessPolicy.classifiedBlockers).toEqual([]); + expect(readinessPolicy.postReadinessSummary.headline).toContain("satisfied"); + expect(readinessPolicy.postReadinessSummary.blockers).toEqual([]); + expect(deploymentEvidence.status).toBe("recorded"); + expect(deploymentEvidence.references.some((entry) => entry.kind === "pull-request")).toBe(true); + expect(postShipEvidence.status).toBe("stable"); + expect(postShipEvidence.followUpRequired).toBe(false); + expect(followUpLineage.status).toBe("none"); + expect(followUpLineage.recommendedDrafts).toHaveLength(0); expect(githubMutation.branch.current).toContain("cstack/"); expect(githubMutation.pullRequest.created).toBe(true); expect(githubDelivery.overall.status).toBe("ready"); - expect(stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain("Workflow: ship"); + expect(stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("")).toContain("Workflow: ship"); } finally { stdoutSpy.mockRestore(); } }, 15_000); + + it("inherits and overrides initiative metadata", async () => { + const buildRunId = await seedBuildRun(repoDir); + const reviewRunId = await seedInitiativeReviewRun(repoDir, buildRunId); + await writeGitHubFixture({ + createdPullRequest: { + reviewDecision: "APPROVED", + mergeStateStatus: "CLEAN" + }, + issues: [ + { + number: 333, + title: "Ship initiative", + state: "CLOSED", + url: "https://github.com/ganesh47/cstack/issues/333", + closedAt: "2026-03-14T00:00:00.000Z" + } + ], + prChecks: [ + { name: "deliver/test", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/20" }, + { name: "deliver/typecheck", bucket: "pass", state: "completed", workflow: "CI", link: "https://github.com/ganesh47/cstack/actions/runs/21" } + ], + actions: [ + { databaseId: 4, workflowName: "Release", status: "completed", conclusion: "success", url: "https://github.com/ganesh47/cstack/actions/runs/4" } + ], + security: { + dependabot: [], + codeScanning: [] + } + }); + + await runShip(repoDir, [ + "--from-run", + reviewRunId, + "Ship initiative cleanup" + ]); + + const inheritedRun = await readRun( + repoDir, + (await listRuns(repoDir)).filter((entry) => entry.workflow === "ship")[0]!.id + ); + expect(inheritedRun.inputs.initiativeId).toBe("initiative-ship"); + expect(inheritedRun.inputs.initiativeTitle).toBe("Platform initiative"); + + await runShip(repoDir, [ + "--from-run", + reviewRunId, + "--initiative", + "initiative-ship-override", + "--initiative-title", + "Override ship initiative", + "Ship initiative cleanup" + ]); + + const overrideRun = await readRun( + repoDir, + (await listRuns(repoDir)).filter((entry) => entry.workflow === "ship")[0]!.id + ); + + expect(overrideRun.inputs.initiativeId).toBe("initiative-ship-override"); + expect(overrideRun.inputs.initiativeTitle).toBe("Override ship initiative"); + expect(inheritedRun.id).not.toBe(overrideRun.id); + }, 15_000); }); diff --git a/test/spec.test.ts b/test/spec.test.ts index 69664c1..5e3e3b6 100644 --- a/test/spec.test.ts +++ b/test/spec.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { promises as fs } from "node:fs"; import { chmodSync } from "node:fs"; +import { runRerun } from "../src/commands/rerun.js"; import { runSpec } from "../src/commands/spec.js"; import { listRuns, readRun } from "../src/run.js"; import type { RunRecord } from "../src/types.js"; @@ -131,6 +132,128 @@ describe("runSpec", () => { expect(promptBody).toContain("Billing cleanup findings"); }); + it("writes planning issue artifacts for issue-linked spec runs", async () => { + await runSpec(repoDir, ["--issue", "123", "Draft the first vertical slice."]); + + const runs = await listRuns(repoDir); + const run = runs.find((entry) => entry.workflow === "spec"); + expect(run?.inputs.planningIssueNumber).toBe(123); + + const runDir = path.dirname(run!.finalPath); + const draftBody = await fs.readFile(path.join(runDir, "artifacts", "issue-draft.md"), "utf8"); + const lineage = JSON.parse(await fs.readFile(path.join(runDir, "artifacts", "issue-lineage.json"), "utf8")) as { + planningIssueNumber: number; + currentRun: { runId: string }; + }; + const promptBody = await fs.readFile(run!.promptPath, "utf8"); + + expect(draftBody).toContain("Planning Issue Draft: #123"); + expect(lineage.planningIssueNumber).toBe(123); + expect(lineage.currentRun.runId).toBe(run!.id); + expect(promptBody).toContain("GitHub issue: #123"); + }); + + it("inherits planning issue linkage from a linked discover run", async () => { + const discoverRunId = await seedDiscoverRun(repoDir); + const discoverRun = await readRun(repoDir, discoverRunId); + discoverRun.inputs.planningIssueNumber = 123; + await fs.writeFile(path.join(repoDir, ".cstack", "runs", discoverRunId, "run.json"), `${JSON.stringify(discoverRun, null, 2)}\n`, "utf8"); + await fs.writeFile( + path.join(repoDir, ".cstack", "runs", discoverRunId, "artifacts", "issue-lineage.json"), + `${JSON.stringify({ + planningIssueNumber: 123, + currentRun: { runId: discoverRunId, workflow: "discover" }, + downstreamPullRequests: [], + downstreamReleases: [] + }, null, 2)}\n`, + "utf8" + ); + + await runSpec(repoDir, ["--from-run", discoverRunId]); + + const runs = await listRuns(repoDir); + const run = runs.find((entry) => entry.workflow === "spec"); + expect(run?.inputs.planningIssueNumber).toBe(123); + + const promptBody = await fs.readFile(run!.promptPath, "utf8"); + const lineage = JSON.parse(await fs.readFile(path.join(path.dirname(run!.finalPath), "artifacts", "issue-lineage.json"), "utf8")) as { + planningIssueNumber: number; + sourceRun?: { runId: string; workflow: string }; + }; + + expect(promptBody).toContain("GitHub issue: #123"); + expect(lineage.planningIssueNumber).toBe(123); + expect(lineage.sourceRun?.runId).toBe(discoverRunId); + expect(lineage.sourceRun?.workflow).toBe("discover"); + }); + + it("records initiative linkage and initiative graph for spec runs", async () => { + const baselineRunId = "2026-03-14T11-00-00-spec-cache-base"; + const baselineRunDir = path.join(repoDir, ".cstack", "runs", baselineRunId); + const baselineRun: RunRecord = { + id: baselineRunId, + workflow: "spec", + createdAt: "2026-03-14T11:00:00.000Z", + updatedAt: "2026-03-14T11:00:05.000Z", + status: "completed", + cwd: repoDir, + gitBranch: "main", + codexVersion: "fake", + codexCommand: ["codex", "exec"], + promptPath: path.join(baselineRunDir, "prompt.md"), + finalPath: path.join(baselineRunDir, "final.md"), + contextPath: path.join(baselineRunDir, "context.md"), + stdoutPath: path.join(baselineRunDir, "stdout.log"), + stderrPath: path.join(baselineRunDir, "stderr.log"), + configSources: [], + summary: "Baseline initiative planning", + inputs: { + userPrompt: "Baseline initiative planning", + initiativeId: "initiative-cache", + initiativeTitle: "Cache rollout" + } + }; + await fs.mkdir(baselineRunDir, { recursive: true }); + await fs.writeFile(path.join(baselineRunDir, "run.json"), `${JSON.stringify(baselineRun, null, 2)}\n`, "utf8"); + await fs.writeFile(path.join(baselineRunDir, "final.md"), "# final\n", "utf8"); + + await runSpec( + repoDir, + ["--initiative", "initiative-cache", "--initiative-title", "Cache rollout", "Draft the first caching slice."] + ); + + const runs = await listRuns(repoDir); + const run = runs.find((entry) => entry.workflow === "spec" && entry.summary === "Draft the first caching slice."); + expect(run?.inputs.initiativeId).toBe("initiative-cache"); + expect(run?.inputs.initiativeTitle).toBe("Cache rollout"); + + const initiativeGraph = JSON.parse( + await fs.readFile(path.join(path.dirname(run!.finalPath), "artifacts", "initiative-graph.json"), "utf8") + ) as { + initiativeId: string; + initiativeTitle?: string; + relatedRuns: Array<{ runId: string; workflow: string }>; + currentRun: { runId: string }; + }; + + expect(initiativeGraph.initiativeId).toBe("initiative-cache"); + expect(initiativeGraph.initiativeTitle).toBe("Cache rollout"); + expect(initiativeGraph.relatedRuns.some((entry) => entry.runId === baselineRunId)).toBe(true); + expect(initiativeGraph.currentRun.runId).toBe(run!.id); + }); + + it("preserves planning issue linkage on rerun", async () => { + await runSpec(repoDir, ["--issue", "123", "Draft the first vertical slice."]); + const initialRuns = await listRuns(repoDir); + const sourceRun = initialRuns.find((entry) => entry.workflow === "spec"); + + const rerunId = await runRerun(repoDir, [sourceRun!.id]); + const rerun = await readRun(repoDir, rerunId); + + expect(rerun.workflow).toBe("spec"); + expect(rerun.inputs.planningIssueNumber).toBe(123); + }); + it("fails closed when the spec stage times out", async () => { const configPath = path.join(repoDir, ".cstack", "config.toml"); const existing = await fs.readFile(configPath, "utf8");