mandrel 1.81.0 → 1.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/README.md +46 -5
- package/.agents/docs/SDLC.md +97 -82
- package/.agents/docs/agentrc-reference.json +10 -2
- package/.agents/docs/configuration.md +4 -1
- package/.agents/docs/execution-reference.md +52 -0
- package/.agents/docs/workflows.md +1 -1
- package/.agents/instructions.md +85 -45
- package/.agents/personas/architect.md +8 -5
- package/.agents/personas/engineer-mobile.md +3 -2
- package/.agents/personas/engineer-web.md +3 -2
- package/.agents/personas/engineer.md +6 -5
- package/.agents/personas/product.md +19 -13
- package/.agents/personas/project-manager.md +9 -8
- package/.agents/personas/qa-engineer.md +10 -6
- package/.agents/personas/refactorer.md +3 -2
- package/.agents/personas/technical-writer.md +2 -1
- package/.agents/personas/ux-designer.md +2 -2
- package/.agents/schemas/agentrc.schema.json +41 -3
- package/.agents/schemas/qa-ledger.schema.json +2 -2
- package/.agents/scripts/acceptance-spec-reconciler.js +143 -59
- package/.agents/scripts/epic-deliver-prepare.js +40 -31
- package/.agents/scripts/epic-plan-decompose.js +2 -5
- package/.agents/scripts/epic-plan-spec.js +16 -19
- package/.agents/scripts/hierarchy-gate.js +11 -11
- package/.agents/scripts/lib/ITicketingProvider.js +4 -3
- package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
- package/.agents/scripts/lib/bdd-scenario-scanner.js +1 -1
- package/.agents/scripts/lib/cli-args.js +1 -5
- package/.agents/scripts/lib/codebase-snapshot.js +1 -1
- package/.agents/scripts/lib/config/explain.js +4 -1
- package/.agents/scripts/lib/config/temp-paths.js +1 -4
- package/.agents/scripts/lib/config-settings-schema.js +30 -1
- package/.agents/scripts/lib/epic-body-sections.js +310 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +38 -1
- package/.agents/scripts/lib/epic-plan-ideation.js +15 -3
- package/.agents/scripts/lib/findings/promote-finding.js +3 -3
- package/.agents/scripts/lib/findings/severity.js +5 -6
- package/.agents/scripts/lib/label-constants.js +7 -17
- package/.agents/scripts/lib/label-taxonomy.js +4 -21
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +105 -9
- package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
- package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/context.js +23 -22
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist-helpers.js +7 -10
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/planning-artifacts.js +4 -38
- package/.agents/scripts/lib/orchestration/epic-plan-lease-guard.js +8 -9
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +11 -5
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/cli-args.js +26 -5
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/plan-epic.js +102 -304
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +32 -29
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +19 -20
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-freshness.js +6 -9
- package/.agents/scripts/lib/orchestration/epic-plan-state-store.js +3 -4
- package/.agents/scripts/lib/orchestration/epic-runner/phases/build-wave-dag.js +1 -1
- package/.agents/scripts/lib/orchestration/epic-runner/phases/snapshot.js +20 -27
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/acceptance-reconciler.js +11 -5
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +22 -59
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +1 -1
- package/.agents/scripts/lib/orchestration/planning-context-budget.js +1 -1
- package/.agents/scripts/lib/orchestration/preflight-cache.js +1 -1
- package/.agents/scripts/lib/orchestration/spec-freshness.js +3 -3
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +1 -1
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +122 -1
- package/.agents/scripts/lib/orchestration/ticketing/bulk.js +5 -8
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +2 -2
- package/.agents/scripts/lib/plan-phase-cleanup.js +1 -2
- package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
- package/.agents/scripts/lib/qa/qa-context-hydrator.js +6 -85
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
- package/.agents/scripts/lib/templates/decomposer-prompts.js +14 -8
- package/.agents/scripts/lifecycle-emit.js +1 -1
- package/.agents/scripts/lint-label-vocabulary.js +2 -3
- package/.agents/scripts/providers/github/mappers.js +0 -3
- package/.agents/scripts/providers/github/tickets.js +7 -18
- package/.agents/scripts/single-story-init.js +0 -1
- package/.agents/scripts/story-init.js +1 -29
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +43 -22
- package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +27 -40
- package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +15 -13
- package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +143 -151
- package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
- package/.agents/skills/core/hydrate-context/SKILL.md +10 -5
- package/.agents/skills/core/knowledge-transfer/SKILL.md +3 -2
- package/.agents/skills/core/scope-triage/SKILL.md +2 -1
- package/.agents/skills/skills.index.json +8 -8
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
- package/.agents/templates/epic-from-idea.md +4 -0
- package/.agents/workflows/audit-to-stories.md +2 -2
- package/.agents/workflows/helpers/code-review.md +11 -9
- package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
- package/.agents/workflows/helpers/deliver-epic.md +173 -490
- package/.agents/workflows/helpers/epic-audit.md +11 -8
- package/.agents/workflows/helpers/epic-deliver-story.md +45 -27
- package/.agents/workflows/helpers/epic-plan-decompose.md +17 -12
- package/.agents/workflows/helpers/epic-plan-spec.md +68 -68
- package/.agents/workflows/helpers/parallel-tooling.md +2 -1
- package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
- package/.agents/workflows/helpers/plan-epic.md +141 -256
- package/.agents/workflows/helpers/plan-story.md +31 -61
- package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
- package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
- package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
- package/.agents/workflows/helpers/single-story-deliver.md +129 -393
- package/.agents/workflows/helpers/worktree-lifecycle.md +1 -1
- package/.agents/workflows/plan.md +8 -8
- package/.agents/workflows/qa-assist.md +2 -1
- package/.agents/workflows/qa-explore.md +63 -32
- package/.agents/workflows/qa-run.md +293 -130
- package/docs/CHANGELOG.md +35 -0
- package/package.json +1 -1
- package/.agents/schemas/qa-finding.schema.json +0 -133
- package/.agents/scripts/lib/issue-link-parser.js +0 -74
- package/.agents/scripts/lib/orchestration/finalize/close-planning-tickets.js +0 -116
- package/.agents/scripts/lib/orchestration/planning-state-manager.js +0 -318
- package/.agents/scripts/lib/story-init/hierarchy-tracer.js +0 -57
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
-
"title": "QaFinding",
|
|
4
|
-
"description": "Structured `F#` finding emitted by the agent-driven QA harness (`.agents/workflows/qa-run.md`, Epic #3214). One object per genuine problem surfaced while sweeping a consumer's Gherkin scenarios through chrome-devtools MCP: a non-allowlisted console error, a failed/error-status network request, or a gross design-token violation. Findings are validated against this schema before they are bundled by likely root cause into operator-approved follow-up ticket drafts (the harness never files tickets autonomously). The shape mirrors the F# convergence in Tech Spec #3285 and is produced in its console-derived subset by `.agents/scripts/lib/qa/console-allowlist.js`.",
|
|
5
|
-
"type": "object",
|
|
6
|
-
"additionalProperties": false,
|
|
7
|
-
"required": [
|
|
8
|
-
"id",
|
|
9
|
-
"classification",
|
|
10
|
-
"surface",
|
|
11
|
-
"symptom",
|
|
12
|
-
"likelyRootCause",
|
|
13
|
-
"disposition",
|
|
14
|
-
"acceptance",
|
|
15
|
-
"evidence"
|
|
16
|
-
],
|
|
17
|
-
"properties": {
|
|
18
|
-
"id": {
|
|
19
|
-
"type": "string",
|
|
20
|
-
"description": "Stable finding identifier, assigned `F1`, `F2`, … in capture order across a surface.",
|
|
21
|
-
"pattern": "^F[0-9]+$"
|
|
22
|
-
},
|
|
23
|
-
"classification": {
|
|
24
|
-
"type": "string",
|
|
25
|
-
"description": "What kind of problem this finding represents. `console-error` and `network-error` are emitted by the instrumentation layer; `design-token` by the visual-inspection layer; `behavior` for a semantic Then-assertion failure.",
|
|
26
|
-
"enum": ["console-error", "network-error", "design-token", "behavior"]
|
|
27
|
-
},
|
|
28
|
-
"surface": {
|
|
29
|
-
"type": "string",
|
|
30
|
-
"description": "The surface (route/screen label) the finding was observed on. Falls back to `unknown` when the harness cannot resolve a surface label.",
|
|
31
|
-
"minLength": 1
|
|
32
|
-
},
|
|
33
|
-
"symptom": {
|
|
34
|
-
"type": "string",
|
|
35
|
-
"description": "A one-line, user-visible description of the problem. Captured evidence (console/network) MUST already be scrubbed of tokens, session cookies, and PII per `.agents/rules/security-baseline.md` before this is rendered."
|
|
36
|
-
},
|
|
37
|
-
"likelyRootCause": {
|
|
38
|
-
"type": ["string", "null"],
|
|
39
|
-
"description": "The agent's best assessment of the underlying cause, or `null` when not yet inferred. Used to bundle findings into follow-up tickets in the assisted drafting step."
|
|
40
|
-
},
|
|
41
|
-
"disposition": {
|
|
42
|
-
"type": "string",
|
|
43
|
-
"description": "Whether the finding blocks the scenario (`blocker`) or is a deferred follow-up (`follow-up`).",
|
|
44
|
-
"enum": ["blocker", "follow-up"]
|
|
45
|
-
},
|
|
46
|
-
"acceptance": {
|
|
47
|
-
"type": ["string", "null"],
|
|
48
|
-
"description": "The acceptance condition that would close the follow-up this finding seeds, or `null` when the finding has not yet been promoted to a draft."
|
|
49
|
-
},
|
|
50
|
-
"foldsInto": {
|
|
51
|
-
"type": "string",
|
|
52
|
-
"description": "Optional. The id of another finding this one is a duplicate of, or folds into during root-cause bundling. Omit for a standalone finding.",
|
|
53
|
-
"pattern": "^F[0-9]+$"
|
|
54
|
-
},
|
|
55
|
-
"security": {
|
|
56
|
-
"type": "boolean",
|
|
57
|
-
"description": "Optional. `true` when the finding has a security dimension (e.g. a leaked token, an auth boundary failure, an injection-prone request) and MUST be routed with security baseline scrutiny. Omit or `false` for a non-security finding."
|
|
58
|
-
},
|
|
59
|
-
"routedTo": {
|
|
60
|
-
"type": "object",
|
|
61
|
-
"description": "Optional finding-to-issue link recording where the finding was routed once it is promoted to a follow-up ticket draft. Omit until the finding has been filed. The same link shape is mirrored on `qa-ledger.schema.json`.",
|
|
62
|
-
"additionalProperties": false,
|
|
63
|
-
"required": ["issue", "url", "kind"],
|
|
64
|
-
"properties": {
|
|
65
|
-
"issue": {
|
|
66
|
-
"type": "integer",
|
|
67
|
-
"description": "The GitHub issue number the finding was routed to.",
|
|
68
|
-
"minimum": 1
|
|
69
|
-
},
|
|
70
|
-
"url": {
|
|
71
|
-
"type": "string",
|
|
72
|
-
"description": "The canonical URL of the routed issue.",
|
|
73
|
-
"minLength": 1
|
|
74
|
-
},
|
|
75
|
-
"kind": {
|
|
76
|
-
"type": "string",
|
|
77
|
-
"description": "The kind of ticket the finding was routed to: a `story`, an `epic`, or a bare `issue`.",
|
|
78
|
-
"enum": ["story", "epic", "issue"]
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
"evidence": {
|
|
83
|
-
"type": "object",
|
|
84
|
-
"description": "Raw signal supporting the finding. Both arrays are always present (possibly empty) so a console-only or network-only finding still carries a well-formed evidence envelope.",
|
|
85
|
-
"additionalProperties": false,
|
|
86
|
-
"required": ["console", "network"],
|
|
87
|
-
"properties": {
|
|
88
|
-
"console": {
|
|
89
|
-
"type": "array",
|
|
90
|
-
"description": "Captured console messages supporting the finding.",
|
|
91
|
-
"items": {
|
|
92
|
-
"type": "object",
|
|
93
|
-
"additionalProperties": false,
|
|
94
|
-
"required": ["level", "text"],
|
|
95
|
-
"properties": {
|
|
96
|
-
"level": {
|
|
97
|
-
"type": "string",
|
|
98
|
-
"description": "Console message level (e.g. `error`, `severe`, `warning`)."
|
|
99
|
-
},
|
|
100
|
-
"text": {
|
|
101
|
-
"type": "string",
|
|
102
|
-
"description": "Console message text, scrubbed of secrets and PII."
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
},
|
|
107
|
-
"network": {
|
|
108
|
-
"type": "array",
|
|
109
|
-
"description": "Captured network requests supporting the finding.",
|
|
110
|
-
"items": {
|
|
111
|
-
"type": "object",
|
|
112
|
-
"additionalProperties": false,
|
|
113
|
-
"required": ["url", "status"],
|
|
114
|
-
"properties": {
|
|
115
|
-
"url": {
|
|
116
|
-
"type": "string",
|
|
117
|
-
"description": "Request URL, scrubbed of secrets and PII (e.g. query-string tokens)."
|
|
118
|
-
},
|
|
119
|
-
"status": {
|
|
120
|
-
"type": "integer",
|
|
121
|
-
"description": "HTTP status code of the response (0 when the request never completed)."
|
|
122
|
-
},
|
|
123
|
-
"method": {
|
|
124
|
-
"type": "string",
|
|
125
|
-
"description": "Optional HTTP method (e.g. `GET`, `POST`)."
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Parse PRD / Tech-Spec / Acceptance-Spec references from a GitHub epic body.
|
|
3
|
-
* Extracted from providers/github.js so link-parsing is not mixed with HTTP
|
|
4
|
-
* transport.
|
|
5
|
-
*
|
|
6
|
-
* Expected markdown conventions in an epic body (case-insensitive, tolerant
|
|
7
|
-
* of the `- [ ]` / `- [x]` checkbox prefix that `epic-plan-spec.js` emits in
|
|
8
|
-
* the `## Planning Artifacts` section):
|
|
9
|
-
* - "PRD: #42" or "prd #42"
|
|
10
|
-
* - "Tech Spec: #43" / "Technical Spec: #43" / "tech-spec: #43"
|
|
11
|
-
* - "Acceptance Spec: #44" / "acceptance-spec: #44" / "accept spec: #44"
|
|
12
|
-
*
|
|
13
|
-
* Story #2091 added the `acceptanceSpec` slot so the
|
|
14
|
-
* `closePlanningArtifacts()` cascade in `epic-deliver-finalize.js` can close
|
|
15
|
-
* the `context::acceptance-spec` ticket Epic #2001 introduces alongside the
|
|
16
|
-
* existing PRD / Tech-Spec pair.
|
|
17
|
-
*
|
|
18
|
-
* Story #3848: regexes are now scoped to the `## Planning Artifacts` section
|
|
19
|
-
* only. Prose elsewhere in the body (e.g. bundled-follow-up notes that
|
|
20
|
-
* mention a foreign Epic's spec ticket by number) can no longer collide with
|
|
21
|
-
* the machine-managed list items.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
const PRD_RE = /(?:PRD|prd)[:\s]+#(\d+)/;
|
|
25
|
-
const TECH_SPEC_RE = /(?:Tech Spec|tech.?spec|technical.?spec)[:\s]+#(\d+)/i;
|
|
26
|
-
const ACCEPTANCE_SPEC_RE =
|
|
27
|
-
/(?:Acceptance Spec|acceptance.?spec|accept.?spec)[:\s]+#(\d+)/i;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Extract the `## Planning Artifacts` section text from an Epic body.
|
|
31
|
-
* Returns the slice from just after the heading line to the next `##`-level
|
|
32
|
-
* heading (exclusive), or to end-of-string when no following heading is
|
|
33
|
-
* present. Returns an empty string when the section is absent.
|
|
34
|
-
*
|
|
35
|
-
* @param {string} body
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function extractPlanningArtifactsSection(body) {
|
|
39
|
-
// Step 1: locate the heading.
|
|
40
|
-
const startMatch = body.match(/^##\s+Planning Artifacts[^\n]*/m);
|
|
41
|
-
if (!startMatch) return '';
|
|
42
|
-
// Step 2: slice from just after the heading to the next ## heading (or EOB).
|
|
43
|
-
const afterHeading = body.slice(startMatch.index + startMatch[0].length);
|
|
44
|
-
const nextHeadingMatch = afterHeading.match(/\n##\s/);
|
|
45
|
-
return nextHeadingMatch
|
|
46
|
-
? afterHeading.slice(0, nextHeadingMatch.index)
|
|
47
|
-
: afterHeading;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* @param {string|null|undefined} body
|
|
52
|
-
* @returns {{ prd: number|null, techSpec: number|null, acceptanceSpec: number|null }}
|
|
53
|
-
*/
|
|
54
|
-
export function parseLinkedIssues(body) {
|
|
55
|
-
const result = { prd: null, techSpec: null, acceptanceSpec: null };
|
|
56
|
-
if (typeof body !== 'string' || body.length === 0) return result;
|
|
57
|
-
|
|
58
|
-
// Scope all regex matching to the canonical Planning Artifacts section so
|
|
59
|
-
// prose references elsewhere in the body do not shadow the machine-managed
|
|
60
|
-
// list items. When the section is absent every slot stays null — the caller
|
|
61
|
-
// (plan-epic.js) treats null as "not yet linked" and creates fresh tickets.
|
|
62
|
-
const section = extractPlanningArtifactsSection(body);
|
|
63
|
-
if (section.length === 0) return result;
|
|
64
|
-
|
|
65
|
-
const prdMatch = section.match(PRD_RE);
|
|
66
|
-
if (prdMatch) result.prd = Number.parseInt(prdMatch[1], 10);
|
|
67
|
-
const specMatch = section.match(TECH_SPEC_RE);
|
|
68
|
-
if (specMatch) result.techSpec = Number.parseInt(specMatch[1], 10);
|
|
69
|
-
const acceptanceMatch = section.match(ACCEPTANCE_SPEC_RE);
|
|
70
|
-
if (acceptanceMatch) {
|
|
71
|
-
result.acceptanceSpec = Number.parseInt(acceptanceMatch[1], 10);
|
|
72
|
-
}
|
|
73
|
-
return result;
|
|
74
|
-
}
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
// .agents/scripts/lib/orchestration/finalize/close-planning-tickets.js
|
|
2
|
-
/**
|
|
3
|
-
* close-planning-tickets.js — finalize helper that closes the three
|
|
4
|
-
* planning context tickets (PRD / Tech Spec / Acceptance Spec) linked
|
|
5
|
-
* from the Epic body's `## Planning Artifacts` section.
|
|
6
|
-
*
|
|
7
|
-
* Extracted from `/deliver` Phase 7.1 prose (the `gh issue close`
|
|
8
|
-
* sequence) so the lifecycle Finalizer listener has a single async
|
|
9
|
-
* helper to call. Reuses `parseLinkedIssues` so the three planning ids
|
|
10
|
-
* are read from the same canonical body shape that `epic-plan` writes
|
|
11
|
-
* via `ensurePlanningArtifacts` and that `closePlanningArtifacts` (the
|
|
12
|
-
* existing post-merge close-tail helper) consumes.
|
|
13
|
-
*
|
|
14
|
-
* Story #2894 / Task #2904 (Epic #2880).
|
|
15
|
-
*
|
|
16
|
-
* Contract:
|
|
17
|
-
* - Input: { epicId, provider, transitionFn?, logger? }
|
|
18
|
-
* - Output: { closed, alreadyClosed, failed, details[] }
|
|
19
|
-
*
|
|
20
|
-
* Idempotency: a planning ticket that is already closed (provider
|
|
21
|
-
* `state === 'closed'`) is counted under `alreadyClosed` and not
|
|
22
|
-
* re-transitioned. A failed close on one ticket records a `failed`
|
|
23
|
-
* entry and the helper continues with the remaining tickets — finalize
|
|
24
|
-
* surfaces the count and the listener decides whether to escalate via
|
|
25
|
-
* `agent::blocked`.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
import { parseLinkedIssues } from '../../issue-link-parser.js';
|
|
29
|
-
import { Logger } from '../../Logger.js';
|
|
30
|
-
import { STATE_LABELS, transitionTicketState } from '../ticketing.js';
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* @param {object} args
|
|
34
|
-
* @param {number} args.epicId — numeric Epic ticket id.
|
|
35
|
-
* @param {object} args.provider — ITicketingProvider. Must implement
|
|
36
|
-
* `getTicket(id)` returning at least `{ body, state, linkedIssues? }`.
|
|
37
|
-
* @param {Function} [args.transitionFn] — override of
|
|
38
|
-
* `transitionTicketState` for tests.
|
|
39
|
-
* @param {object} [args.logger] — { info, warn, debug } surface.
|
|
40
|
-
* @returns {Promise<{
|
|
41
|
-
* closed: number,
|
|
42
|
-
* alreadyClosed: number,
|
|
43
|
-
* failed: number,
|
|
44
|
-
* details: Array<{ kind: 'prd'|'techSpec'|'acceptanceSpec', id: number|null, status: 'closed'|'already-closed'|'failed'|'skipped', detail?: string }>,
|
|
45
|
-
* }>}
|
|
46
|
-
*/
|
|
47
|
-
export async function closePlanningTickets({
|
|
48
|
-
epicId,
|
|
49
|
-
provider,
|
|
50
|
-
transitionFn = transitionTicketState,
|
|
51
|
-
logger = Logger,
|
|
52
|
-
} = {}) {
|
|
53
|
-
if (!Number.isInteger(epicId) || epicId < 1) {
|
|
54
|
-
throw new TypeError(
|
|
55
|
-
'closePlanningTickets: epicId must be a positive integer',
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
if (!provider || typeof provider.getTicket !== 'function') {
|
|
59
|
-
throw new TypeError(
|
|
60
|
-
'closePlanningTickets: provider must implement getTicket()',
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
const epic = await provider.getTicket(epicId);
|
|
65
|
-
const linked = epic?.linkedIssues ?? parseLinkedIssues(epic?.body ?? '');
|
|
66
|
-
|
|
67
|
-
const kinds = /** @type {const} */ ([
|
|
68
|
-
['prd', linked?.prd ?? null],
|
|
69
|
-
['techSpec', linked?.techSpec ?? null],
|
|
70
|
-
['acceptanceSpec', linked?.acceptanceSpec ?? null],
|
|
71
|
-
]);
|
|
72
|
-
|
|
73
|
-
const settled = await Promise.all(
|
|
74
|
-
kinds.map(async ([kind, id]) => {
|
|
75
|
-
if (!Number.isInteger(id) || id <= 0) {
|
|
76
|
-
return { kind, id: null, status: 'skipped', detail: 'no-link' };
|
|
77
|
-
}
|
|
78
|
-
let snapshot;
|
|
79
|
-
try {
|
|
80
|
-
snapshot = await provider.getTicket(id);
|
|
81
|
-
} catch (err) {
|
|
82
|
-
const detail = err?.message ?? String(err);
|
|
83
|
-
logger.warn?.(
|
|
84
|
-
`[finalize/close-planning-tickets] read of ${kind} #${id} failed: ${detail}`,
|
|
85
|
-
);
|
|
86
|
-
return { kind, id, status: 'failed', detail };
|
|
87
|
-
}
|
|
88
|
-
if (snapshot?.state === 'closed') {
|
|
89
|
-
return { kind, id, status: 'already-closed' };
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
await transitionFn(provider, id, STATE_LABELS.DONE, { cascade: false });
|
|
93
|
-
logger.info?.(
|
|
94
|
-
`[finalize/close-planning-tickets] closed ${kind} #${id} for Epic #${epicId}`,
|
|
95
|
-
);
|
|
96
|
-
return { kind, id, status: 'closed' };
|
|
97
|
-
} catch (err) {
|
|
98
|
-
const detail = err?.message ?? String(err);
|
|
99
|
-
logger.warn?.(
|
|
100
|
-
`[finalize/close-planning-tickets] close of ${kind} #${id} failed: ${detail}`,
|
|
101
|
-
);
|
|
102
|
-
return { kind, id, status: 'failed', detail };
|
|
103
|
-
}
|
|
104
|
-
}),
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
let closed = 0;
|
|
108
|
-
let alreadyClosed = 0;
|
|
109
|
-
let failed = 0;
|
|
110
|
-
for (const row of settled) {
|
|
111
|
-
if (row.status === 'closed') closed += 1;
|
|
112
|
-
else if (row.status === 'already-closed') alreadyClosed += 1;
|
|
113
|
-
else if (row.status === 'failed') failed += 1;
|
|
114
|
-
}
|
|
115
|
-
return { closed, alreadyClosed, failed, details: settled };
|
|
116
|
-
}
|
|
@@ -1,318 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file planning-state-manager.js
|
|
3
|
-
* Extracted state-healing and artifact idempotency logic for epic planning.
|
|
4
|
-
*
|
|
5
|
-
* Invariant: After planning completes, exactly ONE open PRD and ONE open
|
|
6
|
-
* Tech Spec must exist as sub-issues of the Epic. All others are closed
|
|
7
|
-
* (state_reason: 'not_planned') and detached.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Logger } from '../Logger.js';
|
|
11
|
-
import {
|
|
12
|
-
ACCEPTANCE_NA,
|
|
13
|
-
AGENT_LABELS,
|
|
14
|
-
CONTEXT_LABELS,
|
|
15
|
-
} from '../label-constants.js';
|
|
16
|
-
import { concurrentMap } from '../util/concurrent-map.js';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Snapshot of the Epic's planning-artifact state as seen / mutated by
|
|
20
|
-
* {@link PlanningStateManager}. Mirrors the `epic-plan-state` structured
|
|
21
|
-
* comment schema owned by `epic-plan-state-store.js`, narrowed to the fields
|
|
22
|
-
* this manager reads and rewrites.
|
|
23
|
-
*
|
|
24
|
-
* @typedef {object} PlanCheckpointState
|
|
25
|
-
* @property {number} epicId Epic ticket id.
|
|
26
|
-
* @property {{ prd: (number | null), techSpec: (number | null), acceptanceSpec: (number | null) }} linkedIssues Canonical planning-artifact references persisted on the Epic.
|
|
27
|
-
* @property {string} body Current Epic body (may include a `## Planning Artifacts` section).
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Heals and de-duplicates the Epic's PRD / Tech Spec planning artifacts so
|
|
32
|
-
* the post-state invariant holds: exactly ONE open PRD and ONE open Tech
|
|
33
|
-
* Spec, both linked from the Epic body. All redundant artifacts are closed
|
|
34
|
-
* (`state_reason: 'not_planned'`) and detached.
|
|
35
|
-
*/
|
|
36
|
-
export class PlanningStateManager {
|
|
37
|
-
/**
|
|
38
|
-
* @param {import('../ITicketingProvider.js').ITicketingProvider} provider Ticketing provider used for ticket + sub-issue mutations.
|
|
39
|
-
*/
|
|
40
|
-
constructor(provider) {
|
|
41
|
-
this.provider = provider;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Resolve existing planning artifacts and heal / clean up the graph.
|
|
46
|
-
*
|
|
47
|
-
* With `force=false` (normal run):
|
|
48
|
-
* - Pick the canonical PRD / Tech Spec (first open one, else first overall).
|
|
49
|
-
* - Heal dangling `epic.linkedIssues` references.
|
|
50
|
-
* - Close + detach any redundant artifacts (posting an audit-trace
|
|
51
|
-
* notification first).
|
|
52
|
-
* - Persist the healed references back to the Epic body if they were not
|
|
53
|
-
* already written.
|
|
54
|
-
*
|
|
55
|
-
* With `force=true` (re-plan requested): **overwrite the canonical
|
|
56
|
-
* context tickets in place.** Keep the canonical PRD / Tech Spec /
|
|
57
|
-
* Acceptance Spec **open** and keep `epic.linkedIssues` pointing at them
|
|
58
|
-
* (so the caller can refresh their bodies via `provider.updateTicket`),
|
|
59
|
-
* close + detach only the redundant duplicate artifacts (exactly as the
|
|
60
|
-
* non-force path does), and strip the `## Planning Artifacts` section
|
|
61
|
-
* from the Epic body so the caller re-appends it pointing at the same
|
|
62
|
-
* preserved IDs. Context tickets are no longer closed-and-recreated on a
|
|
63
|
-
* forced re-plan; only Feature/Story child tickets keep that behaviour
|
|
64
|
-
* (handled outside this manager by `forceCloseExistingChildren`).
|
|
65
|
-
*
|
|
66
|
-
* Mutates `epic.linkedIssues` and `epic.body` in place.
|
|
67
|
-
*
|
|
68
|
-
* @param {PlanCheckpointState & { linkedIssues: object, body: string, id: number }} epic Epic ticket with mutable planning state.
|
|
69
|
-
* @param {boolean} [force=false] When true, preserve canonical context tickets (overwrite-in-place) and strip the Planning Artifacts body section for a forced re-plan.
|
|
70
|
-
* @returns {Promise<void>}
|
|
71
|
-
* @throws {Error} Propagates non-404/410 errors from `provider.updateTicket`. All other provider errors are intentionally swallowed.
|
|
72
|
-
*/
|
|
73
|
-
async healAndCleanupArtifacts(epic, force = false) {
|
|
74
|
-
const epicId = epic.id;
|
|
75
|
-
const relatedTickets = await this.provider.getTickets(epicId);
|
|
76
|
-
this.provider.primeTicketCache(relatedTickets);
|
|
77
|
-
|
|
78
|
-
// One descriptor per planning-artifact type. Each entry parameterizes
|
|
79
|
-
// the label filter, the canonical reference key on `epic.linkedIssues`,
|
|
80
|
-
// and a human-readable name for the heal log line, so the per-type
|
|
81
|
-
// filter / canonical / heal / successor logic runs from a single loop
|
|
82
|
-
// instead of three inlined copies.
|
|
83
|
-
const ARTIFACT_TYPES = [
|
|
84
|
-
{ label: CONTEXT_LABELS.PRD, key: 'prd', name: 'PRD' },
|
|
85
|
-
{ label: CONTEXT_LABELS.TECH_SPEC, key: 'techSpec', name: 'Tech Spec' },
|
|
86
|
-
{
|
|
87
|
-
label: CONTEXT_LABELS.ACCEPTANCE_SPEC,
|
|
88
|
-
key: 'acceptanceSpec',
|
|
89
|
-
name: 'Acceptance Spec',
|
|
90
|
-
},
|
|
91
|
-
];
|
|
92
|
-
|
|
93
|
-
// Resolve each artifact type: collect ALL matching tickets (open AND
|
|
94
|
-
// closed) so stale sub-issue links get cleaned up regardless of state;
|
|
95
|
-
// pick the canonical one (first open, else first overall); heal a
|
|
96
|
-
// dangling `epic.linkedIssues` reference; and record the resolved
|
|
97
|
-
// canonical id for successor resolution below.
|
|
98
|
-
const resolved = ARTIFACT_TYPES.map((descriptor) => {
|
|
99
|
-
const all = relatedTickets.filter((t) =>
|
|
100
|
-
t.labels.includes(descriptor.label),
|
|
101
|
-
);
|
|
102
|
-
const canonical = all.find((t) => t.state === 'open') ?? all[0] ?? null;
|
|
103
|
-
|
|
104
|
-
if (!epic.linkedIssues[descriptor.key] && canonical?.state === 'open') {
|
|
105
|
-
epic.linkedIssues[descriptor.key] = canonical.id;
|
|
106
|
-
Logger.info(
|
|
107
|
-
`[Epic Planner] Healed dangling ${descriptor.name} reference: #${epic.linkedIssues[descriptor.key]}`,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const canonicalId = epic.linkedIssues[descriptor.key] ?? canonical?.id;
|
|
112
|
-
return { ...descriptor, all, canonicalId };
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
// Identify redundant artifacts: everything that is NOT the canonical one.
|
|
116
|
-
const redundant = resolved.flatMap((r) =>
|
|
117
|
-
r.all.filter((t) => t.id !== r.canonicalId),
|
|
118
|
-
);
|
|
119
|
-
|
|
120
|
-
// Map artifact label → resolved canonical id, for successor resolution.
|
|
121
|
-
const successorByLabel = new Map(
|
|
122
|
-
resolved.map((r) => [r.label, r.canonicalId]),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
// Bound the close+detach mutation burst at 3 so wide redundancy
|
|
126
|
-
// cleanup does not race the GitHub secondary rate limit.
|
|
127
|
-
await concurrentMap(
|
|
128
|
-
redundant,
|
|
129
|
-
async (t) => {
|
|
130
|
-
const descriptor = ARTIFACT_TYPES.find((d) =>
|
|
131
|
-
t.labels.includes(d.label),
|
|
132
|
-
);
|
|
133
|
-
const successorId = descriptor
|
|
134
|
-
? successorByLabel.get(descriptor.label)
|
|
135
|
-
: undefined;
|
|
136
|
-
Logger.info(
|
|
137
|
-
`[Epic Planner] Cleaning up redundant artifact #${t.id} (superseded by #${successorId})...`,
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
// Close the issue if it's still open
|
|
141
|
-
if (t.state === 'open') {
|
|
142
|
-
try {
|
|
143
|
-
await this.provider.postComment(t.id, {
|
|
144
|
-
type: 'notification',
|
|
145
|
-
body: `⚠️ **Audit Trace**: This planning artifact was created during an interrupted or failed orchestration run and is now **superseded by #${successorId}**. \n\nClosing this issue to maintain a single source of truth for Epic #${epicId}.`,
|
|
146
|
-
});
|
|
147
|
-
} catch (err) {
|
|
148
|
-
Logger.warn(
|
|
149
|
-
`[Epic Planner] Could not post audit-trace comment on #${t.id}: ${err.message}`,
|
|
150
|
-
);
|
|
151
|
-
}
|
|
152
|
-
await this.provider.updateTicket(t.id, {
|
|
153
|
-
state: 'closed',
|
|
154
|
-
state_reason: 'not_planned',
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// Detach the sub-issue from the Epic to prevent orphaned links
|
|
159
|
-
try {
|
|
160
|
-
await this.provider.removeSubIssue(epicId, t.id);
|
|
161
|
-
Logger.info(
|
|
162
|
-
`[Epic Planner] Detached #${t.id} from Epic #${epicId}.`,
|
|
163
|
-
);
|
|
164
|
-
} catch (_err) {
|
|
165
|
-
// Already detached or API doesn't support — safe to ignore
|
|
166
|
-
Logger.info(
|
|
167
|
-
`[Epic Planner] Could not detach #${t.id} (may already be detached).`,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
},
|
|
171
|
-
{ concurrency: 3 },
|
|
172
|
-
);
|
|
173
|
-
|
|
174
|
-
// Persist healed references to the body if needed.
|
|
175
|
-
if (
|
|
176
|
-
!force &&
|
|
177
|
-
epic.linkedIssues.prd &&
|
|
178
|
-
epic.linkedIssues.techSpec &&
|
|
179
|
-
!epic.body.includes('## Planning Artifacts')
|
|
180
|
-
) {
|
|
181
|
-
Logger.info(
|
|
182
|
-
`[Epic Planner] Persisting healed references to Epic body...`,
|
|
183
|
-
);
|
|
184
|
-
const appendBody = `\n\n## Planning Artifacts\n- [ ] PRD: #${epic.linkedIssues.prd}\n- [ ] Tech Spec: #${epic.linkedIssues.techSpec}\n`;
|
|
185
|
-
await this.provider.updateTicket(epicId, {
|
|
186
|
-
body: epic.body + appendBody,
|
|
187
|
-
});
|
|
188
|
-
epic.body += appendBody;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Force re-plan: overwrite the canonical context tickets in place.
|
|
192
|
-
// The redundant duplicates have already been closed + detached by the
|
|
193
|
-
// cleanup pass above. Here we only strip the `## Planning Artifacts`
|
|
194
|
-
// body section so `planEpic` re-appends it pointing at the same
|
|
195
|
-
// preserved canonical IDs. We deliberately DO NOT close the canonical
|
|
196
|
-
// PRD / Tech Spec / Acceptance Spec, and we keep `epic.linkedIssues`
|
|
197
|
-
// pointing at them so the caller can refresh their bodies.
|
|
198
|
-
if (force) {
|
|
199
|
-
const stripped = epic.body.replace(
|
|
200
|
-
/\n*## Planning Artifacts[\s\S]*$/,
|
|
201
|
-
'',
|
|
202
|
-
);
|
|
203
|
-
if (stripped !== epic.body) {
|
|
204
|
-
await this.provider.updateTicket(epicId, { body: stripped });
|
|
205
|
-
epic.body = stripped;
|
|
206
|
-
Logger.info(
|
|
207
|
-
'[Epic Planner] Stripped old Planning Artifacts section from Epic body (canonical context tickets preserved for in-place overwrite).',
|
|
208
|
-
);
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Compute whether an Epic is ready to transition from `agent::review-spec`
|
|
215
|
-
* to `agent::ready` by inspecting the state of its context tickets.
|
|
216
|
-
*
|
|
217
|
-
* An Epic is ready when **all three** context tickets — PRD, Tech Spec, and
|
|
218
|
-
* Acceptance Spec — exist and are closed. The acceptance-spec requirement
|
|
219
|
-
* can be waived by attaching the `acceptance::n-a` label to the Epic, in
|
|
220
|
-
* which case acceptance-spec presence and state are ignored. Missing PRD
|
|
221
|
-
* or Tech Spec is never waivable through this method.
|
|
222
|
-
*
|
|
223
|
-
* This predicate is **pure** with respect to the world: it reads tickets
|
|
224
|
-
* via the provider and computes a verdict. Callers (e.g. the planning
|
|
225
|
-
* runner) are responsible for actually flipping the label when
|
|
226
|
-
* `ready === true`.
|
|
227
|
-
*
|
|
228
|
-
* @param {number} epicId Epic ticket id.
|
|
229
|
-
* @returns {Promise<{ ready: boolean, reason: string, contexts: { prd: ('open' | 'closed' | 'missing'), techSpec: ('open' | 'closed' | 'missing'), acceptanceSpec: ('open' | 'closed' | 'missing' | 'waived') } }>}
|
|
230
|
-
* `ready` is `true` when the Epic satisfies the readiness gate.
|
|
231
|
-
* `reason` is a machine-readable code suitable for logging / metrics
|
|
232
|
-
* (`all-context-closed`, `acceptance-waived`, `prd-missing`,
|
|
233
|
-
* `prd-open`, `tech-spec-missing`, `tech-spec-open`,
|
|
234
|
-
* `acceptance-spec-missing`, `acceptance-spec-open`).
|
|
235
|
-
* `contexts` is per-axis status for callers that want to render the
|
|
236
|
-
* verdict alongside ticket links.
|
|
237
|
-
*/
|
|
238
|
-
async computeReviewReadiness(epicId) {
|
|
239
|
-
const epic = await this.provider.getTicket(epicId);
|
|
240
|
-
const relatedTickets = await this.provider.getTickets(epicId);
|
|
241
|
-
this.provider.primeTicketCache(relatedTickets);
|
|
242
|
-
|
|
243
|
-
const epicLabels = epic?.labels ?? [];
|
|
244
|
-
const acceptanceWaived = epicLabels.includes(ACCEPTANCE_NA);
|
|
245
|
-
|
|
246
|
-
const findByLabel = (label) =>
|
|
247
|
-
relatedTickets.find((t) => (t.labels ?? []).includes(label)) ?? null;
|
|
248
|
-
|
|
249
|
-
const prd = findByLabel(CONTEXT_LABELS.PRD);
|
|
250
|
-
const techSpec = findByLabel(CONTEXT_LABELS.TECH_SPEC);
|
|
251
|
-
const acceptanceSpec = findByLabel(CONTEXT_LABELS.ACCEPTANCE_SPEC);
|
|
252
|
-
|
|
253
|
-
const axisStatus = (ticket) => {
|
|
254
|
-
if (!ticket) return 'missing';
|
|
255
|
-
return ticket.state === 'closed' ? 'closed' : 'open';
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
const contexts = {
|
|
259
|
-
prd: axisStatus(prd),
|
|
260
|
-
techSpec: axisStatus(techSpec),
|
|
261
|
-
acceptanceSpec: acceptanceWaived ? 'waived' : axisStatus(acceptanceSpec),
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
if (contexts.prd === 'missing') {
|
|
265
|
-
return { ready: false, reason: 'prd-missing', contexts };
|
|
266
|
-
}
|
|
267
|
-
if (contexts.prd === 'open') {
|
|
268
|
-
return { ready: false, reason: 'prd-open', contexts };
|
|
269
|
-
}
|
|
270
|
-
if (contexts.techSpec === 'missing') {
|
|
271
|
-
return { ready: false, reason: 'tech-spec-missing', contexts };
|
|
272
|
-
}
|
|
273
|
-
if (contexts.techSpec === 'open') {
|
|
274
|
-
return { ready: false, reason: 'tech-spec-open', contexts };
|
|
275
|
-
}
|
|
276
|
-
if (!acceptanceWaived) {
|
|
277
|
-
if (contexts.acceptanceSpec === 'missing') {
|
|
278
|
-
return { ready: false, reason: 'acceptance-spec-missing', contexts };
|
|
279
|
-
}
|
|
280
|
-
if (contexts.acceptanceSpec === 'open') {
|
|
281
|
-
return { ready: false, reason: 'acceptance-spec-open', contexts };
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
return {
|
|
286
|
-
ready: true,
|
|
287
|
-
reason: acceptanceWaived ? 'acceptance-waived' : 'all-context-closed',
|
|
288
|
-
contexts,
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* If the Epic satisfies {@link computeReviewReadiness}, flip it from
|
|
294
|
-
* `agent::review-spec` to `agent::ready`. Returns the verdict plus the
|
|
295
|
-
* label transition that was applied (if any). No-ops when readiness is
|
|
296
|
-
* not yet satisfied — callers are expected to retry on the next planning
|
|
297
|
-
* tick rather than block.
|
|
298
|
-
*
|
|
299
|
-
* @param {number} epicId
|
|
300
|
-
* @returns {Promise<{ ready: boolean, reason: string, contexts: object, transitioned: boolean }>}
|
|
301
|
-
*/
|
|
302
|
-
async flipEpicToReadyIfContextClosed(epicId) {
|
|
303
|
-
const verdict = await this.computeReviewReadiness(epicId);
|
|
304
|
-
if (!verdict.ready) {
|
|
305
|
-
return { ...verdict, transitioned: false };
|
|
306
|
-
}
|
|
307
|
-
await this.provider.updateTicket(epicId, {
|
|
308
|
-
labels: {
|
|
309
|
-
add: [AGENT_LABELS.READY],
|
|
310
|
-
remove: [AGENT_LABELS.REVIEW_SPEC],
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
Logger.info(
|
|
314
|
-
`[Epic Planner] Epic #${epicId} → ${AGENT_LABELS.READY} (${verdict.reason}).`,
|
|
315
|
-
);
|
|
316
|
-
return { ...verdict, transitioned: true };
|
|
317
|
-
}
|
|
318
|
-
}
|