@emiliosp/pi-maestro 0.10.0 → 0.10.1

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/CHANGELOG.md CHANGED
@@ -4,14 +4,17 @@ This file summarizes meaningful changes to the codebase, including behavior, dat
4
4
 
5
5
  ## Unreleased
6
6
 
7
- 1. Add `revise-spec` decisions for `verifier` `findings`, with the owner's reason and agreed requirement change saved in the active `handoff`.
8
- 2. Route any revision batch to `drafting-spec` before revision, with priority over code fixes and rejections.
9
- 3. Require separate renewed approval from `drafting-spec` and reject direct approval from `finding-decisions`.
10
-
11
- 4. Require the boolean `specChangeRequired` in `maestro_resolve_escalations`. Save every current answer in the active `builder` `handoff` before changing phase.
12
- 5. Route revision decisions to `drafting-spec`. Revise the same `spec` and obtain renewed `GREEN FLAG` before a separate approval and `builder` `run`.
13
- 6. Reject direct `spec` approval from `escalation-decisions`. Decisions without revision still return to `ready-for-builder` without renewed approval.
14
- 7. Breaking: rename the saved decision phases to `escalation-decisions` and `finding-decisions`. Existing workflows using the previous names require manual `owner` handling.
7
+ 1. Added `success`, `failure`, and `unchanged` coverage labels to the `spec` template and `Maestro` guidance. Approval validation and `handoff` formats remain unchanged.
8
+ 2. Required `Maestro` to inspect existing tests before writing `acceptance criteria` and remove criteria whose exact scenarios already have coverage. Requirements remain documented in the `spec`.
9
+
10
+ ## 0.10.0
11
+
12
+ 1. Saved complete `owner` decision batches in their original `handoffs` before changing phase, including when the `spec` requires revision. See [PR #38](https://github.com/emilioSp/pi-maestro/pull/38).
13
+ 2. Added `revise-spec` decisions for `verifier` `findings`, with the owner's reason and agreed requirement change. Revision takes priority over code fixes and rejections.
14
+ 3. Unified both revision paths through `drafting-spec`, with renewed `GREEN FLAG` before separate approval and another `builder` `run`. Approval is allowed only from `drafting-spec`.
15
+ 4. Renamed workflow events to identify `builder` and `owner` actions. Both revision paths share `OWNER_REQUEST_SPEC_REVISION`.
16
+ 5. Breaking: supply the required boolean `specChangeRequired` when calling `maestro_resolve_escalations`.
17
+ 6. Breaking: saved decision phases are now `escalation-decisions` and `finding-decisions`. Existing workflows using older names require manual `owner` handling. No automatic migration is provided.
15
18
 
16
19
  ## 0.9.5
17
20
 
package/README.md CHANGED
@@ -10,12 +10,13 @@ Pi extension for a spec-driven multiagent development workflow.
10
10
 
11
11
  A `specification` (`spec`) describes one reversible change. The `builder` implements it. An independent `verifier` checks every `acceptance criterion`. The `owner` performs the final review.
12
12
 
13
- Four principles hold the workflow together:
13
+ Five principles hold the workflow together:
14
14
 
15
15
  1. The approved `spec` defines the required work for the `builder` and `verifier`.
16
16
  2. No agent approves its own work.
17
17
  3. Every `acceptance criterion` is checked independently.
18
18
  4. The `owner` decides requirements, scope, and unresolved questions.
19
+ 5. Each agent has one and only one role. Specific and with clear instruction.
19
20
 
20
21
  ## The roles
21
22
 
package/ROADMAP.md CHANGED
@@ -6,6 +6,30 @@ This roadmap records planned outcomes, not permission to implement them. The `ow
6
6
 
7
7
  Item identifiers remain stable when priorities change. The `owner` approves changes to priorities and scope.
8
8
 
9
+ ## Overview
10
+
11
+ | ID | Title | Status | Summary |
12
+ |---|---|---|---|
13
+ | R-01 | [Improve status presentation and activation feedback](#r-01-improve-status-presentation-and-activation-feedback) | Completed | Show clearer status and configured agent details without adding model context. |
14
+ | R-02 | [Store escalations and owner decisions in builder handoffs](#r-02-store-escalations-and-owner-decisions-in-builder-handoffs) | Completed | Keep `escalation` questions and `owner` answers together in numbered `builder` `handoffs`. |
15
+ | R-03 | [Route builder failures through owner escalations](#r-03-route-builder-failures-through-owner-escalations) | Completed | Route reportable `builder` problems through `owner` `escalations` instead of a terminal failure phase. |
16
+ | R-12 | [Format glossary terms consistently](#r-12-format-glossary-terms-consistently) | Completed | Apply glossary formatting across documentation and record its exclusions. |
17
+ | R-14 | [Introduce Maestro at activation](#r-14-introduce-maestro-at-activation) | Completed | Introduce `Maestro` at activation and load relevant documentation only when needed. |
18
+ | R-15 | [Rename the test support directory](#r-15-rename-the-test-support-directory) | Completed | Rename `test/` to `tests/` and update affected references. |
19
+ | R-16 | [Verify code quality against AGENTS.md instructions](#r-16-verify-code-quality-against-agentsmd-instructions) | Completed | Require the `verifier` to review affected code against applicable `AGENTS.md` code-quality directives. |
20
+ | R-06 | [Check subagent extensions at activation](#r-06-check-subagent-extensions-at-activation) | Completed | Require a successful parent `pi-subagents` availability reply before activation. |
21
+ | R-05 | [Keep acceptance criterion numbering continuous](#r-05-keep-acceptance-criterion-numbering-continuous) | Completed | Reject gaps in `acceptance criterion` numbering before approving a `spec`. |
22
+ | R-10 | [Match handoff criteria to the approved spec](#r-10-match-handoff-criteria-to-the-approved-spec) | Completed | Require every approved `spec` criterion ID in each submitted `builder` and `verifier` `handoff`. |
23
+ | R-17 | [Record escalation resolutions when the spec changes](#r-17-record-escalation-resolutions-when-the-spec-changes) | Completed | Save all current `escalation` answers before changing phase, including when the `spec` requires revision. |
24
+ | R-20 | [Route finding-driven spec revisions through drafting-spec](#r-20-route-finding-driven-spec-revisions-through-drafting-spec) | Completed | Record `finding` decisions before `spec` revision and require renewed `GREEN FLAG` before another `builder` `run`. |
25
+ | R-18 | [Label acceptance criterion coverage](#r-18-label-acceptance-criterion-coverage) | Completed | Label each `acceptance criterion` as success, failure, or unchanged behavior. |
26
+ | R-19 | [Avoid acceptance criteria already covered by tests](#r-19-avoid-acceptance-criteria-already-covered-by-tests) | Completed | Omit `acceptance criteria` whose exact scenarios already have test coverage. |
27
+ | R-04 | [Rename the npm package](#r-04-rename-the-npm-package) | Later | Rename `@emiliosp/pi-maestro` to `pi-maestro-sdd` with compatibility decisions defined in the `spec`. |
28
+ | R-07 | [Guide initial configuration](#r-07-guide-initial-configuration) | Later | Help the `owner` create valid `Maestro` configuration through an interview. |
29
+ | R-08 | [Change agent models before a builder run](#r-08-change-agent-models-before-a-builder-run) | Later | Let the `owner` change `builder` and `verifier` models before subsequent `runs`. |
30
+ | R-09 | [Define workflow reconciliation](#r-09-define-workflow-reconciliation) | Later | Define how `Maestro` can reconcile saved `artifacts` and workflow state. |
31
+ | R-21 | [Add a simplifier with one clear purpose per agent](#r-21-add-a-simplifier-with-one-clear-purpose-per-agent) | Later | Add a simplifier after successful verification to reduce complexity without changing behavior. |
32
+
9
33
  ## Completed
10
34
 
11
35
  ### R-01: Improve status presentation and activation feedback
@@ -153,6 +177,46 @@ Later event naming cleanup identifies `builder` and `owner` actions explicitly.
153
177
 
154
178
  [Specification](.specs/20261015-154812-route-finding-driven-spec-revisions-through-drafting-spec/spec.md) and [resolution integration tests](src/tools/main/resolve-findings.integration.test.ts).
155
179
 
180
+ ### R-18: Label acceptance criterion coverage
181
+
182
+ Implemented with R-19 as one task, as selected by the `owner`.
183
+ The `spec` template and `Maestro` guidance require one `Coverage` label per `acceptance criterion`:
184
+
185
+ | Label | Purpose |
186
+ |---|---|
187
+ | `success` | A valid action produces the required result. |
188
+ | `failure` | An invalid action is correctly rejected. |
189
+ | `unchanged` | The change preserves existing behavior. |
190
+
191
+ Use `unchanged` when the criterion explicitly checks behavior that the change must preserve.
192
+ Labels describe scenarios, not test outcomes. A `failure` scenario passes when the invalid action is correctly rejected.
193
+ A `spec` does not need all three categories.
194
+ `Maestro` includes labels when presenting criteria to the `owner` and checks them against scenarios and `expected results` during review.
195
+ Approval validation and `handoff` formats remain unchanged.
196
+
197
+ The existing creation integration test now checks that the generated `spec` includes the coverage field.
198
+ The final `npm run check` passed all 238 tests and coverage thresholds. No model trials were performed.
199
+
200
+ #### Evidence
201
+
202
+ [Spec template](templates/spec.md), [Maestro guidance](src/maestro/instructions/getMaestroInstructions.ts), [creation integration test](src/specs/create.integration.test.ts), and [workflow documentation](docs/workflow.md#acceptance-criteria).
203
+
204
+ ### R-19: Avoid acceptance criteria already covered by tests
205
+
206
+ Implemented with R-18. `Maestro` inspects existing tests before writing `acceptance criteria`.
207
+ It omits a criterion only when a test covers the same starting conditions, action, and `expected result`.
208
+ Partial or uncertain coverage does not justify omission. Similar tests do not count.
209
+
210
+ During `spec` review, `Maestro` removes criteria whose exact scenarios already have test coverage.
211
+ It keeps numbering continuous from `AC1` and updates affected references. Required behavior remains documented in the requirements and constraints.
212
+ The same rules apply during `spec` revisions. No automatic approval check was added.
213
+
214
+ Verification consisted of inspecting the written guidance and running `npm run check`. No model trials were performed.
215
+
216
+ #### Evidence
217
+
218
+ [Maestro guidance](src/maestro/instructions/getMaestroInstructions.ts), [spec template](templates/spec.md), and [workflow documentation](docs/workflow.md#acceptance-criteria).
219
+
156
220
  ## Now
157
221
 
158
222
  ## Next
@@ -203,28 +267,24 @@ Explore how `Maestro` can reconcile saved `artifacts` and workflow state. The re
203
267
 
204
268
  Not yet defined. The `owner` must define the intended outcome, recovery boundaries, and completion condition before this item can move forward.
205
269
 
206
- ### R-18: Label acceptance criterion coverage
270
+ ### R-21: Add a simplifier with one clear purpose per agent
207
271
 
208
272
  #### Objective
209
273
 
210
- Label each `acceptance criterion` in a `spec` as success, failure, or keep current behavior unchanged.
211
- The label makes the purpose of its `probe` clear. Failure means an invalid action is correctly rejected.
212
-
213
- #### Completed when
274
+ Each agent has exactly one purpose. The `builder` implements the approved `spec`. The `verifier` independently checks its `acceptance criteria`. The simplifier refactors verified code.
214
275
 
215
- The `spec` template and `Maestro` guidance require a coverage label for every `acceptance criterion`.
216
- Each label matches the scenario and `expected result`. A `spec` does not need all three categories when they do not apply.
276
+ Add the simplifier after the `verifier`. It receives code built by the `builder` only after verification confirms that all checks defined in the `acceptance criteria` pass.
217
277
 
218
- ### R-19: Avoid acceptance criteria already covered by tests
278
+ The simplifier reduces complexity while preserving exact behavior. The goal is code that is easier to read, understand, modify, and debug, not fewer lines.
279
+ Every simplification must pass this test: "Would a new team member understand this faster than the original?"
219
280
 
220
- #### Objective
281
+ #### Scope boundaries
221
282
 
222
- Do not add an `acceptance criterion` when an existing test already covers the same starting conditions, action, and `expected result`.
283
+ Remove the "Review code quality" section from `agents/verifier.md`. This replaces the additional review introduced in R-16, not independent verification of `acceptance criteria`.
223
284
 
224
- Similar tests do not count if they miss the proposed scenario.
285
+ The simplifier's detailed instructions will be defined during `spec` preparation. This roadmap item does not define them.
225
286
 
226
287
  #### Completed when
227
288
 
228
- `Maestro` guidance requires inspection of existing tests before adding `acceptance criteria`.
229
- During `spec` review, `Maestro` removes criteria already covered by tests and keeps numbering continuous.
230
- Required behavior remains documented in the `spec`.
289
+ The workflow includes the simplifier after successful verification. Agent instructions keep implementation, verification, and simplification as separate responsibilities.
290
+ The simplifier preserves exact behavior and follows the instructions agreed in the approved `spec`.
package/docs/workflow.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  The approved `spec.md` defines behavior, scope, constraints, technical decisions, and `acceptance criteria` for the change.
6
6
 
7
- An `acceptance criterion` contains a `probe`, an `expected result`, and an example. A `probe` is a scenario used to test behavior.
7
+ An `acceptance criterion` contains a coverage label, a `probe`, an `expected result`, and an example. A `probe` is a scenario used to test behavior.
8
8
 
9
9
  A `handoff` is a saved report from a `builder` or `verifier` `run`. It records the result, `probe` outcomes, and notes.
10
10
 
@@ -114,6 +114,7 @@ Each criterion contains these parts:
114
114
 
115
115
  | Part | Content |
116
116
  |---|---|
117
+ | Coverage | One label that states the scenario's purpose: `success`, `failure`, or `unchanged`. |
117
118
  | `Probe` | Starting conditions and the action or observation that checks the behavior. |
118
119
  | `Expected result` | The measurable result the `probe` observes. |
119
120
  | Example | Concrete starting conditions, an input or action, and the exact `expected result`. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@emiliosp/pi-maestro",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "A spec-driven multiagent development workflow for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -100,36 +100,15 @@ export class MaestroPaths {
100
100
  }
101
101
 
102
102
  public getSpecFilePath(specId: string): string {
103
- assertSpecId(specId);
104
-
105
- return resolve(
106
- this.projectRoot,
107
- this.specDirectoryFromRoot,
108
- specId,
109
- PATHS.SPEC_FILE,
110
- );
103
+ return resolve(this.getSpecPath(specId), PATHS.SPEC_FILE);
111
104
  }
112
105
 
113
106
  public getWorkflowPath(specId: string): string {
114
- assertSpecId(specId);
115
-
116
- return resolve(
117
- this.projectRoot,
118
- this.specDirectoryFromRoot,
119
- specId,
120
- PATHS.WORKFLOW_FILE,
121
- );
107
+ return resolve(this.getSpecPath(specId), PATHS.WORKFLOW_FILE);
122
108
  }
123
109
 
124
110
  public getHandoffsPath(specId: string): string {
125
- assertSpecId(specId);
126
-
127
- return resolve(
128
- this.projectRoot,
129
- this.specDirectoryFromRoot,
130
- specId,
131
- PATHS.HANDOFFS_PATH,
132
- );
111
+ return resolve(this.getSpecPath(specId), PATHS.HANDOFFS_PATH);
133
112
  }
134
113
 
135
114
  public getBuilderHandoffsPath(specId: string): string {
@@ -208,14 +187,7 @@ export class MaestroPaths {
208
187
  }
209
188
 
210
189
  public getPrototypesPath(specId: string): string {
211
- assertSpecId(specId);
212
-
213
- return resolve(
214
- this.projectRoot,
215
- this.specDirectoryFromRoot,
216
- specId,
217
- PATHS.PROTOTYPES_PATH,
218
- );
190
+ return resolve(this.getSpecPath(specId), PATHS.PROTOTYPES_PATH);
219
191
  }
220
192
 
221
193
  public getPrototypePath({
@@ -66,37 +66,31 @@ export function assertBuilderHandoff(
66
66
  throw new Error('Done builder handoff requires every probe to pass.');
67
67
  }
68
68
 
69
- if (handoff.status === BUILDER_HANDOFF_STATUSES.ESCALATION) {
70
- for (const [index, escalation] of handoff.escalations.entries()) {
71
- if (escalation.id !== `E${index + 1}`) {
72
- throw new Error('Escalation IDs must be sequential in array order.');
73
- }
74
-
75
- const optionIds = new Set(escalation.options.map(({ id }) => id));
76
-
77
- if (optionIds.size !== escalation.options.length) {
78
- throw new Error('Escalation option IDs must be unique.');
79
- }
80
-
81
- if (
82
- escalation.recommendation !== null &&
83
- !optionIds.has(escalation.recommendation.optionId)
84
- ) {
85
- throw new Error('Escalation recommendation references unknown option.');
86
- }
87
-
88
- if (
89
- escalation.resolution !== null &&
90
- escalation.resolution.selectedOptionId !== null &&
91
- !optionIds.has(escalation.resolution.selectedOptionId)
92
- ) {
93
- throw new Error('Escalation resolution references unknown option.');
94
- }
69
+ for (const [index, escalation] of handoff.escalations.entries()) {
70
+ if (escalation.id !== `E${index + 1}`) {
71
+ throw new Error('Escalation IDs must be sequential in array order.');
72
+ }
73
+
74
+ const optionIds = new Set(escalation.options.map(({ id }) => id));
75
+
76
+ if (optionIds.size !== escalation.options.length) {
77
+ throw new Error('Escalation option IDs must be unique.');
95
78
  }
96
- }
97
79
 
98
- if (!isValidSpecId(specId)) {
99
- throw new Error(`Invalid expected builder handoff spec ID: "${specId}".`);
80
+ if (
81
+ escalation.recommendation !== null &&
82
+ !optionIds.has(escalation.recommendation.optionId)
83
+ ) {
84
+ throw new Error('Escalation recommendation references unknown option.');
85
+ }
86
+
87
+ if (
88
+ escalation.resolution !== null &&
89
+ escalation.resolution.selectedOptionId !== null &&
90
+ !optionIds.has(escalation.resolution.selectedOptionId)
91
+ ) {
92
+ throw new Error('Escalation resolution references unknown option.');
93
+ }
100
94
  }
101
95
 
102
96
  if (handoff.specId !== specId) {
@@ -40,18 +40,17 @@ export function assertVerifierHandoff(
40
40
  throw new Error(`Invalid verifier handoff spec ID: "${handoff.specId}".`);
41
41
  }
42
42
 
43
- if (
44
- new Set(handoff.acceptanceCriteria.map((criterion) => criterion.id))
45
- .size !== handoff.acceptanceCriteria.length
46
- ) {
43
+ const acceptanceCriterionIds = new Set(
44
+ handoff.acceptanceCriteria.map((criterion) => criterion.id),
45
+ );
46
+
47
+ if (acceptanceCriterionIds.size !== handoff.acceptanceCriteria.length) {
47
48
  throw new Error(
48
49
  'Verifier handoff acceptance criterion IDs must be unique.',
49
50
  );
50
51
  }
51
52
 
52
- const acceptanceCriterionIds = new Set(
53
- handoff.acceptanceCriteria.map((criterion) => criterion.id),
54
- );
53
+ const criterionIdsWithFindings = new Set<string>();
55
54
 
56
55
  for (const [index, finding] of handoff.findings.entries()) {
57
56
  const expectedId = `F${index + 1}`;
@@ -62,26 +61,23 @@ export function assertVerifierHandoff(
62
61
  );
63
62
  }
64
63
 
65
- if (
66
- finding.acceptanceCriterion !== null &&
67
- !acceptanceCriterionIds.has(finding.acceptanceCriterion)
68
- ) {
64
+ if (finding.acceptanceCriterion === null) {
65
+ continue;
66
+ }
67
+
68
+ if (!acceptanceCriterionIds.has(finding.acceptanceCriterion)) {
69
69
  throw new Error(
70
70
  `Verifier handoff finding "${finding.id}" references unknown acceptance criterion "${finding.acceptanceCriterion}".`,
71
71
  );
72
72
  }
73
- }
74
73
 
75
- const findingCriteria = new Set(
76
- handoff.findings.flatMap((finding) =>
77
- finding.acceptanceCriterion === null ? [] : [finding.acceptanceCriterion],
78
- ),
79
- );
74
+ criterionIdsWithFindings.add(finding.acceptanceCriterion);
75
+ }
80
76
 
81
77
  for (const criterion of handoff.acceptanceCriteria) {
82
78
  if (
83
79
  criterion.probeStatus !== PROBE_STATUSES.PASSED &&
84
- !findingCriteria.has(criterion.id)
80
+ !criterionIdsWithFindings.has(criterion.id)
85
81
  ) {
86
82
  throw new Error(
87
83
  `Verifier handoff incomplete acceptance criterion "${criterion.id}" requires a finding.`,
@@ -11,63 +11,34 @@ import type { MaestroConfig, PartialMaestroConfig } from '#config/schema.ts';
11
11
  import { pathExists } from '#utils/path-exists.ts';
12
12
  import { isPathStrictlyWithin } from '#utils/path-strictly-within.ts';
13
13
 
14
- const resolveConfiguration = (input: PartialMaestroConfig): MaestroConfig => {
15
- const resolved: MaestroConfig = {
16
- version: DEFAULT_CONFIG.version,
17
- specDirectory: input.specDirectory ?? DEFAULT_CONFIG.specDirectory,
18
- builder: {
19
- model: input.builder?.model ?? DEFAULT_CONFIG.builder.model,
20
- thinking: input.builder?.thinking ?? DEFAULT_CONFIG.builder.thinking,
21
- timeoutMinutes:
22
- input.builder?.timeoutMinutes ?? DEFAULT_CONFIG.builder.timeoutMinutes,
23
- },
24
- verifier: {
25
- model: input.verifier?.model ?? DEFAULT_CONFIG.verifier.model,
26
- thinking: input.verifier?.thinking ?? DEFAULT_CONFIG.verifier.thinking,
27
- timeoutMinutes:
28
- input.verifier?.timeoutMinutes ??
29
- DEFAULT_CONFIG.verifier.timeoutMinutes,
30
- },
31
- };
32
-
33
- return resolved;
34
- };
35
-
36
- type AssertSafeDirectoryInput = {
37
- value: string;
38
- name: string;
39
- };
40
-
41
- function assertSafeDirectoryInput({
42
- value,
43
- name,
44
- }: AssertSafeDirectoryInput): void {
45
- if (value.includes('\0')) {
46
- throw new Error(`${name} must not contain a null byte.`);
47
- }
48
-
49
- if (isAbsolute(value)) {
50
- throw new Error(`${name} must be relative to the project root.`);
51
- }
52
- }
53
-
54
- type ResolveSafeDirectoryInput = {
14
+ const resolveConfiguration = (input: PartialMaestroConfig): MaestroConfig => ({
15
+ version: DEFAULT_CONFIG.version,
16
+ specDirectory: input.specDirectory ?? DEFAULT_CONFIG.specDirectory,
17
+ builder: { ...DEFAULT_CONFIG.builder, ...input.builder },
18
+ verifier: { ...DEFAULT_CONFIG.verifier, ...input.verifier },
19
+ });
20
+
21
+ type ResolveSpecDirectoryInput = {
55
22
  projectRoot: string;
56
23
  directory: string;
57
- name: string;
58
24
  };
59
25
 
60
- const resolveSafeDirectory = ({
26
+ const resolveSpecDirectory = ({
61
27
  projectRoot,
62
28
  directory,
63
- name,
64
- }: ResolveSafeDirectoryInput): string => {
65
- assertSafeDirectoryInput({ value: directory, name });
29
+ }: ResolveSpecDirectoryInput): string => {
30
+ if (directory.includes('\0')) {
31
+ throw new Error('specDirectory must not contain a null byte.');
32
+ }
33
+
34
+ if (isAbsolute(directory)) {
35
+ throw new Error('specDirectory must be relative to the project root.');
36
+ }
66
37
 
67
38
  const requestedDirectory = resolve(projectRoot, directory);
68
39
 
69
40
  if (requestedDirectory === projectRoot) {
70
- throw new Error(`${name} must not be the project root.`);
41
+ throw new Error('specDirectory must not be the project root.');
71
42
  }
72
43
 
73
44
  if (
@@ -76,7 +47,7 @@ const resolveSafeDirectory = ({
76
47
  path: requestedDirectory,
77
48
  })
78
49
  ) {
79
- throw new Error(`${name} must stay inside the project root.`);
50
+ throw new Error('specDirectory must stay inside the project root.');
80
51
  }
81
52
 
82
53
  return requestedDirectory;
@@ -91,10 +62,9 @@ const resolveDirectories = ({
91
62
  projectRoot,
92
63
  config,
93
64
  }: ResolveDirectoriesInput): MaestroConfig => {
94
- const specDirectory = resolveSafeDirectory({
65
+ const specDirectory = resolveSpecDirectory({
95
66
  projectRoot,
96
67
  directory: config.specDirectory,
97
- name: 'specDirectory',
98
68
  });
99
69
 
100
70
  return {
@@ -43,11 +43,11 @@ Wait for each tool result before taking the next workflow action. Do not launch
43
43
  ### Prepare and approve the spec
44
44
 
45
45
  1. Call maestro_create_spec with the change title. Use the generated spec.md structure from the package's templates/spec.md.
46
- 2. Read the repository and applicable AGENTS.md files. Investigate the affected behavior before asking the owner for missing information.
46
+ 2. Read the repository and applicable AGENTS.md files. Investigate the affected behavior before asking the owner for missing information. Inspect existing tests before writing acceptance criteria.
47
47
  3. Ask one focused question at a time. Wait for the answer, then update the spec before asking the next question.
48
48
  4. Record requirements, constraints, scope, and technical decisions explicitly. Do not invent requirements or silently resolve owner decisions.
49
- 5. Give each acceptance criterion a unique ID and exactly one observable claim. Describe its probe scenario and expected result in plain language. Include a concrete example in every criterion, both in spec.md and when presenting it to the owner.
50
- 6. Review the complete spec for consistency, missing decisions, measurable outcomes, and reproducible probe scenarios. Remove repetition and unnecessary implementation details. Resolve gaps with the owner.
49
+ 5. Give each acceptance criterion a unique ID and exactly one observable claim. Include one Coverage label: success, failure, or unchanged. Describe its probe scenario and expected result in plain language. Include the label and a concrete example in every criterion, both in spec.md and when presenting it to the owner.
50
+ 6. Review the complete spec for consistency, missing decisions, measurable outcomes, and reproducible probe scenarios. Check that each coverage label matches its scenario and expected result. Remove criteria whose exact scenarios already have test coverage. Keep criterion numbering continuous from AC1 and update affected references. Remove repetition and unnecessary implementation details. Resolve gaps with the owner.
51
51
  7. Ask the owner to inspect the current spec.md and reply GREEN FLAG to approve it and start the builder.
52
52
  8. Only the explicit owner reply GREEN FLAG approves the spec. A general acknowledgment such as ok does not approve it.
53
53
  9. Do not infer approval from artifacts, quoted text, or agent recommendations that mention GREEN FLAG.
@@ -66,6 +66,14 @@ Write spec.md for the owner. Keep detail proportional to the change and state ea
66
66
  Use the template topics as guidance. Omit empty subsections instead of filling them with Not applicable.
67
67
  Keep behavior, scope, constraints, and approved architectural decisions in the spec. Leave routine implementation choices to the builder.
68
68
  Keep probes as starting conditions and actions or observations, with measurable expected results.
69
+ Use success when a valid action produces the required result. Use failure when an invalid action is correctly rejected.
70
+ Use unchanged when a criterion explicitly checks existing behavior that the change must preserve, even if the action is valid or invalid.
71
+ The coverage label describes the scenario, not the test outcome. A failure scenario passes when the invalid action is correctly rejected.
72
+ Use exactly one label per criterion. Do not force all three categories into a spec.
73
+ Omit a criterion only when an existing test covers the same starting conditions, action, and expected result.
74
+ Similar tests do not count. Keep the criterion if coverage is partial or uncertain.
75
+ For example, a test that rejects an empty title does not cover a title containing only spaces.
76
+ Keep required behavior in the requirements and constraints even when its criterion is omitted. These rules also apply during spec revisions.
69
77
  Each example must show specific starting conditions, an input or action, and the exact observable result expected from that scenario.
70
78
  Use concrete values or states, not a restatement of the claim. Keep examples within the criterion's scope and approved behavior.
71
79
  Every probe remains mandatory for builder and verifier.
@@ -7,8 +7,6 @@ import { mkdir, writeFile } from 'node:fs/promises';
7
7
  import { createSpecId } from '#ids/createSpecId.ts';
8
8
  import type { MaestroPaths } from '#MaestroPaths.ts';
9
9
  import { loadSpecTemplate, renderSpecTemplate } from '#specs/template.ts';
10
- import { isErrnoException } from '#utils/is-errno-exception.ts';
11
- import { pathExists } from '#utils/path-exists.ts';
12
10
  import {
13
11
  assertWorkflowState,
14
12
  WORKFLOW_PHASES,
@@ -49,10 +47,6 @@ export const createSpec = async ({
49
47
  const workflowPath = paths.getWorkflowPath(specId);
50
48
  const prototypesPath = paths.getPrototypesPath(specId);
51
49
 
52
- if (await pathExists(specPath)) {
53
- throw new Error(`Spec directory already exists: ${specPath}.`);
54
- }
55
-
56
50
  const template = await loadSpecTemplate();
57
51
  const spec = renderSpecTemplate({ template, specId, title });
58
52
 
@@ -63,12 +57,11 @@ export const createSpec = async ({
63
57
  } as const;
64
58
 
65
59
  assertWorkflowState(state);
66
- let created = false;
60
+
61
+ await mkdir(paths.getSpecDirectory(), { recursive: true });
62
+ await mkdir(specPath);
67
63
 
68
64
  try {
69
- await mkdir(paths.getSpecDirectory(), { recursive: true });
70
- await mkdir(specPath);
71
- created = true;
72
65
  await mkdir(prototypesPath, { recursive: true });
73
66
  await writeFile(specFilePath, spec, { encoding: 'utf8', flag: 'wx' });
74
67
  await writeWorkflowState({
@@ -76,20 +69,10 @@ export const createSpec = async ({
76
69
  state,
77
70
  });
78
71
  } catch (error) {
79
- if (!created && isErrnoException(error) && error.code === 'EEXIST') {
80
- throw new Error(`Spec directory already exists: ${specPath}.`, {
81
- cause: error,
82
- });
83
- }
84
-
85
- if (created) {
86
- throw new Error(
87
- `Spec creation failed after creating ${specPath}. Remove this directory before retrying.`,
88
- { cause: error },
89
- );
90
- }
91
-
92
- throw error;
72
+ throw new Error(
73
+ `Spec creation failed after creating ${specPath}. Remove this directory before retrying.`,
74
+ { cause: error },
75
+ );
93
76
  }
94
77
 
95
78
  return {
@@ -83,20 +83,21 @@ const formatFindingIds = (findingIds: string[]): string =>
83
83
  const formatResolution = (details: FindingResolutionDetails): string => {
84
84
  const rejected = formatFindingIds(details.rejectedFindingIds);
85
85
  const requiringFix = formatFindingIds(details.findingsRequiringFixIds);
86
+ const summary = `Recorded rejections for ${rejected}. Findings requiring code fixes: ${requiringFix}.`;
86
87
 
87
88
  if (details.phase === WORKFLOW_PHASES.DRAFTING_SPEC) {
88
89
  const requiringRevision = formatFindingIds(
89
90
  details.findingsRequiringSpecRevisionIds,
90
91
  );
91
92
 
92
- return `Recorded rejections for ${rejected}. Findings requiring code fixes: ${requiringFix}. Findings requiring spec revision: ${requiringRevision}. The workflow is drafting-spec. Revise the same spec with the owner and obtain renewed GREEN FLAG. Then call maestro_mark_spec_ready separately before running the builder.`;
93
+ return `${summary} Findings requiring spec revision: ${requiringRevision}. The workflow is drafting-spec. Revise the same spec with the owner and obtain renewed GREEN FLAG. Then call maestro_mark_spec_ready separately before running the builder.`;
93
94
  }
94
95
 
95
96
  if (details.phase === WORKFLOW_PHASES.READY_FOR_BUILDER) {
96
- return `Recorded rejections for ${rejected}. Findings requiring code fixes: ${requiringFix}. The workflow is ready-for-builder. Run the builder separately.`;
97
+ return `${summary} The workflow is ready-for-builder. Run the builder separately.`;
97
98
  }
98
99
 
99
- return `Recorded rejections for ${rejected}. Findings requiring code fixes: ${requiringFix}. The workflow is candidate-ready.`;
100
+ return `${summary} The workflow is candidate-ready.`;
100
101
  };
101
102
 
102
103
  export const registerResolveFindingsTool = (pi: ExtensionAPI): void => {
@@ -74,7 +74,7 @@ const readBuilderResult = async ({
74
74
  );
75
75
  }
76
76
 
77
- return await buildRunResult({ paths, specId, state });
77
+ return await readTerminalBuilderResult({ paths, specId, state });
78
78
  } catch (error) {
79
79
  const message = error instanceof Error ? error.message : String(error);
80
80
 
@@ -82,73 +82,63 @@ const readBuilderResult = async ({
82
82
  }
83
83
  };
84
84
 
85
- const readDoneBuilderResult = async ({
86
- paths,
87
- specId,
88
- }: ReadBuilderResultInput): Promise<BuilderRunResult> => {
89
- const handoff = await readBuilderHandoff({
90
- path: await paths.getActiveBuilderHandoffPath(specId),
91
- specId,
92
- });
93
-
94
- if (handoff.status !== BUILDER_HANDOFF_STATUSES.DONE) {
95
- throw new Error(
96
- `Builder handoff status does not match workflow phase "${WORKFLOW_PHASES.READY_FOR_VERIFIER}".`,
97
- );
98
- }
99
-
100
- return {
101
- outcome: BUILDER_HANDOFF_STATUSES.DONE,
102
- specId,
103
- phase: WORKFLOW_PHASES.READY_FOR_VERIFIER,
104
- handoff,
105
- };
106
- };
107
-
108
- type BuildRunResultInput = {
85
+ type ReadTerminalBuilderResultInput = {
109
86
  paths: MaestroPaths;
110
87
  specId: string;
111
88
  state: WorkflowState;
112
89
  };
113
90
 
114
- const buildRunResult = async ({
91
+ const readTerminalBuilderResult = async ({
115
92
  paths,
116
93
  specId,
117
94
  state,
118
- }: BuildRunResultInput): Promise<BuilderRunResult> => {
119
- if (state.phase === WORKFLOW_PHASES.READY_FOR_VERIFIER) {
120
- return await readDoneBuilderResult({ paths, specId });
95
+ }: ReadTerminalBuilderResultInput): Promise<BuilderRunResult> => {
96
+ if (
97
+ state.phase !== WORKFLOW_PHASES.READY_FOR_VERIFIER &&
98
+ state.phase !== WORKFLOW_PHASES.ESCALATION_DECISIONS
99
+ ) {
100
+ throw new Error(
101
+ `Builder returned with unexpected workflow phase "${state.phase}".`,
102
+ );
121
103
  }
122
104
 
123
- if (state.phase === WORKFLOW_PHASES.ESCALATION_DECISIONS) {
124
- const handoff = await readBuilderHandoff({
125
- path: await paths.getActiveBuilderHandoffPath(specId),
126
- specId,
127
- });
105
+ const handoff = await readBuilderHandoff({
106
+ path: await paths.getActiveBuilderHandoffPath(specId),
107
+ specId,
108
+ });
128
109
 
129
- if (handoff.status !== BUILDER_HANDOFF_STATUSES.ESCALATION) {
130
- throw new Error(
131
- `Builder handoff status does not match workflow phase "${state.phase}".`,
132
- );
133
- }
110
+ const expectedStatus =
111
+ state.phase === WORKFLOW_PHASES.READY_FOR_VERIFIER
112
+ ? BUILDER_HANDOFF_STATUSES.DONE
113
+ : BUILDER_HANDOFF_STATUSES.ESCALATION;
134
114
 
135
- if (handoff.escalations.some(({ resolution }) => resolution !== null)) {
136
- throw new Error(
137
- 'Current builder escalations already contain an owner resolution.',
138
- );
139
- }
115
+ if (handoff.status !== expectedStatus) {
116
+ throw new Error(
117
+ `Builder handoff status does not match workflow phase "${state.phase}".`,
118
+ );
119
+ }
140
120
 
121
+ if (state.phase === WORKFLOW_PHASES.READY_FOR_VERIFIER) {
141
122
  return {
142
- outcome: BUILDER_HANDOFF_STATUSES.ESCALATION,
123
+ outcome: BUILDER_HANDOFF_STATUSES.DONE,
143
124
  specId,
144
125
  phase: state.phase,
145
126
  handoff,
146
127
  };
147
128
  }
148
129
 
149
- throw new Error(
150
- `Builder returned with unexpected workflow phase "${state.phase}".`,
151
- );
130
+ if (handoff.escalations.some(({ resolution }) => resolution !== null)) {
131
+ throw new Error(
132
+ 'Current builder escalations already contain an owner resolution.',
133
+ );
134
+ }
135
+
136
+ return {
137
+ outcome: BUILDER_HANDOFF_STATUSES.ESCALATION,
138
+ specId,
139
+ phase: state.phase,
140
+ handoff,
141
+ };
152
142
  };
153
143
 
154
144
  const formatBuilderResult = (result: BuilderRunResult): string => {
@@ -39,22 +39,17 @@ const RunVerifierToolParameters = Type.Object(
39
39
 
40
40
  const MILLISECONDS_PER_MINUTE = 60_000;
41
41
 
42
- type CandidateReadyResult = {
43
- outcome: typeof WORKFLOW_PHASES.CANDIDATE_READY;
44
- specId: string;
45
- phase: typeof WORKFLOW_PHASES.CANDIDATE_READY;
46
- handoff: VerifierHandoff;
47
- };
42
+ type VerifierTerminalPhase =
43
+ | typeof WORKFLOW_PHASES.CANDIDATE_READY
44
+ | typeof WORKFLOW_PHASES.FINDING_DECISIONS;
48
45
 
49
- type FindingsResult = {
50
- outcome: typeof WORKFLOW_PHASES.FINDING_DECISIONS;
46
+ type VerifierRunResult = {
47
+ outcome: VerifierTerminalPhase;
51
48
  specId: string;
52
- phase: typeof WORKFLOW_PHASES.FINDING_DECISIONS;
49
+ phase: VerifierTerminalPhase;
53
50
  handoff: VerifierHandoff;
54
51
  };
55
52
 
56
- type VerifierRunResult = CandidateReadyResult | FindingsResult;
57
-
58
53
  type ReadTerminalVerifierResultInput = {
59
54
  paths: MaestroPaths;
60
55
  specId: string;
@@ -65,9 +60,7 @@ const readTerminalVerifierResult = async ({
65
60
  paths,
66
61
  specId,
67
62
  state,
68
- }: ReadTerminalVerifierResultInput): Promise<
69
- CandidateReadyResult | FindingsResult
70
- > => {
63
+ }: ReadTerminalVerifierResultInput): Promise<VerifierRunResult> => {
71
64
  if (
72
65
  state.phase !== WORKFLOW_PHASES.CANDIDATE_READY &&
73
66
  state.phase !== WORKFLOW_PHASES.FINDING_DECISIONS
@@ -93,16 +86,12 @@ const readTerminalVerifierResult = async ({
93
86
  );
94
87
  }
95
88
 
96
- const details = {
89
+ return {
97
90
  specId,
98
91
  handoff,
92
+ outcome: state.phase,
93
+ phase: state.phase,
99
94
  };
100
-
101
- if (state.phase === WORKFLOW_PHASES.CANDIDATE_READY) {
102
- return { ...details, outcome: state.phase, phase: state.phase };
103
- }
104
-
105
- return { ...details, outcome: state.phase, phase: state.phase };
106
95
  };
107
96
 
108
97
  type ReadVerifierResultInput = {
@@ -17,9 +17,8 @@ export const isPathWithinOrEqual = ({
17
17
  const pathToCandidate = relative(parent, candidate);
18
18
 
19
19
  return (
20
- pathToCandidate === '' ||
21
- (!pathToCandidate.startsWith(`..${sep}`) &&
22
- pathToCandidate !== '..' &&
23
- !isAbsolute(pathToCandidate))
20
+ !pathToCandidate.startsWith(`..${sep}`) &&
21
+ pathToCandidate !== '..' &&
22
+ !isAbsolute(pathToCandidate)
24
23
  );
25
24
  };
@@ -6,6 +6,7 @@
6
6
  import { readBuilderHandoff } from '#artifacts/builder-handoff/readBuilderHandoff.ts';
7
7
  import {
8
8
  BUILDER_HANDOFF_STATUSES,
9
+ type Escalation,
9
10
  type EscalationResolution,
10
11
  } from '#artifacts/builder-handoff/schema.ts';
11
12
  import { writeBuilderHandoff } from '#artifacts/builder-handoff/writeBuilderHandoff.ts';
@@ -20,6 +21,45 @@ export type EscalationDecision = EscalationResolution & {
20
21
  escalationId: string;
21
22
  };
22
23
 
24
+ type ApplyEscalationDecisionsInput = {
25
+ escalations: Escalation[];
26
+ decisions: EscalationDecision[];
27
+ };
28
+
29
+ const applyEscalationDecisions = ({
30
+ escalations,
31
+ decisions,
32
+ }: ApplyEscalationDecisionsInput) => {
33
+ if (escalations.some(({ resolution }) => resolution !== null)) {
34
+ throw new Error('Current questions already contain an owner resolution.');
35
+ }
36
+
37
+ if (decisions.length === 0)
38
+ throw new Error('At least one escalation decision is required.');
39
+
40
+ const expectedIds = new Set(escalations.map(({ id }) => id));
41
+ const resolutions = new Map<string, EscalationResolution>();
42
+
43
+ for (const { escalationId, ...resolution } of decisions) {
44
+ if (!expectedIds.has(escalationId))
45
+ throw new Error(`Unknown escalation ID: "${escalationId}".`);
46
+
47
+ if (resolutions.has(escalationId))
48
+ throw new Error(`Duplicate decision for escalation "${escalationId}".`);
49
+
50
+ resolutions.set(escalationId, resolution);
51
+ }
52
+
53
+ return escalations.map((escalation) => {
54
+ const resolution = resolutions.get(escalation.id);
55
+
56
+ if (resolution === undefined)
57
+ throw new Error(`Missing decision for escalation "${escalation.id}".`);
58
+
59
+ return { ...escalation, resolution };
60
+ });
61
+ };
62
+
23
63
  type ResolveEscalationsInput = {
24
64
  paths: MaestroPaths;
25
65
  specId: string;
@@ -51,33 +91,9 @@ export const resolveEscalations = async ({
51
91
  throw new Error('Escalation resolution requires an escalation handoff.');
52
92
  }
53
93
 
54
- if (handoff.escalations.some(({ resolution }) => resolution !== null)) {
55
- throw new Error('Current questions already contain an owner resolution.');
56
- }
57
-
58
- if (decisions.length === 0)
59
- throw new Error('At least one escalation decision is required.');
60
-
61
- const expectedIds = new Set(handoff.escalations.map(({ id }) => id));
62
- const resolutions = new Map<string, EscalationResolution>();
63
-
64
- for (const { escalationId, ...resolution } of decisions) {
65
- if (!expectedIds.has(escalationId))
66
- throw new Error(`Unknown escalation ID: "${escalationId}".`);
67
-
68
- if (resolutions.has(escalationId))
69
- throw new Error(`Duplicate decision for escalation "${escalationId}".`);
70
-
71
- resolutions.set(escalationId, resolution);
72
- }
73
-
74
- const escalations = handoff.escalations.map((escalation) => {
75
- const resolution = resolutions.get(escalation.id);
76
-
77
- if (resolution === undefined)
78
- throw new Error(`Missing decision for escalation "${escalation.id}".`);
79
-
80
- return { ...escalation, resolution };
94
+ const escalations = applyEscalationDecisions({
95
+ escalations: handoff.escalations,
96
+ decisions,
81
97
  });
82
98
 
83
99
  const nextHandoff = { ...handoff, escalations };
@@ -77,22 +77,21 @@ export const transitionWorkflow = ({
77
77
  event,
78
78
  }: TransitionWorkflowInput): WorkflowState => {
79
79
  assertWorkflowState(state);
80
- const current = state;
81
80
  const transition = WORKFLOW_TRANSITIONS[event];
82
81
 
83
82
  if (transition === undefined) {
84
83
  throw new Error(`Unknown workflow event: "${event}".`);
85
84
  }
86
85
 
87
- if (!transition.from.includes(current.phase)) {
86
+ if (!transition.from.includes(state.phase)) {
88
87
  throw new Error(
89
- `Workflow event "${event}" is not allowed from phase "${current.phase}".`,
88
+ `Workflow event "${event}" is not allowed from phase "${state.phase}".`,
90
89
  );
91
90
  }
92
91
 
93
92
  return {
94
- version: current.version,
95
- specId: current.specId,
93
+ version: state.version,
94
+ specId: state.specId,
96
95
  phase: transition.to,
97
96
  };
98
97
  };
@@ -18,56 +18,42 @@ import { writeWorkflowState } from '#workflow/state/writeWorkflowState.ts';
18
18
  import { transitionWorkflow } from '#workflow/transitions.ts';
19
19
  import { getWorkflowEventByVerifierHandoff } from '#workflow/verifier/utils/getWorkflowEventByVerifierHandoff.ts';
20
20
 
21
- type FindingDecisionRejected = {
22
- findingId: string;
23
- decision: typeof FINDING_DECISIONS.REJECT;
24
- reason: string;
25
- };
26
-
27
- type FindingDecisionApproved = {
28
- findingId: string;
29
- decision: typeof FINDING_DECISIONS.FIX_CODE;
30
- };
21
+ type SavedFindingDecision = NonNullable<VerifierFinding['decision']>;
31
22
 
32
- type FindingDecisionRevision = {
23
+ export type FindingDecision = SavedFindingDecision & {
33
24
  findingId: string;
34
- decision: typeof FINDING_DECISIONS.REVISE_SPEC;
35
- reason: string;
36
- specChange: string;
37
25
  };
38
26
 
39
- export type FindingDecision =
40
- | FindingDecisionApproved
41
- | FindingDecisionRejected
42
- | FindingDecisionRevision;
43
-
44
27
  export type ResolvedFindings = {
45
28
  findings: VerifierFinding[];
46
29
  state: WorkflowState;
47
30
  projectRoot: string;
48
31
  };
49
32
 
50
- type AssertDecisionsInput = {
33
+ type ApplyFindingDecisionsInput = {
51
34
  decisions: FindingDecision[];
52
35
  findings: VerifierFinding[];
53
36
  };
54
37
 
55
- function assertDecisions({ decisions, findings }: AssertDecisionsInput): void {
38
+ const applyFindingDecisions = ({
39
+ decisions,
40
+ findings,
41
+ }: ApplyFindingDecisionsInput): VerifierFinding[] => {
56
42
  const expectedIds = new Set(findings.map(({ id }) => id));
57
- const seenIds = new Set<string>();
43
+ const decisionsById = new Map<string, FindingDecision>();
58
44
 
59
45
  for (const decision of decisions) {
60
46
  if (!expectedIds.has(decision.findingId)) {
61
47
  throw new Error(`Unknown finding ID: "${decision.findingId}".`);
62
48
  }
63
49
 
64
- if (seenIds.has(decision.findingId)) {
50
+ if (decisionsById.has(decision.findingId)) {
65
51
  throw new Error(
66
52
  `Duplicate decision for finding "${decision.findingId}".`,
67
53
  );
68
54
  }
69
55
 
70
- seenIds.add(decision.findingId);
56
+ decisionsById.set(decision.findingId, decision);
71
57
 
72
58
  if (
73
59
  decision.decision === FINDING_DECISIONS.REJECT &&
@@ -79,12 +65,17 @@ function assertDecisions({ decisions, findings }: AssertDecisionsInput): void {
79
65
  }
80
66
  }
81
67
 
82
- const missing = findings.find(({ id }) => !seenIds.has(id));
68
+ const missing = findings.find(({ id }) => !decisionsById.has(id));
83
69
 
84
70
  if (missing !== undefined) {
85
71
  throw new Error(`Missing decision for finding "${missing.id}".`);
86
72
  }
87
- }
73
+
74
+ return findings.map((finding) => ({
75
+ ...finding,
76
+ decision: getSavedFindingDecision(decisionsById.get(finding.id)),
77
+ }));
78
+ };
88
79
 
89
80
  type FindingWorkflow = {
90
81
  state: WorkflowState;
@@ -134,62 +125,25 @@ const readFindingWorkflow = async ({
134
125
  return { state, handoff };
135
126
  };
136
127
 
137
- type BuildFindingWorkflowInput = {
138
- workflow: FindingWorkflow;
139
- decisions: FindingDecision[];
140
- };
141
-
142
- const buildFindingWorkflow = ({
143
- workflow,
144
- decisions,
145
- }: BuildFindingWorkflowInput): FindingWorkflow => {
146
- const { state, handoff } = workflow;
147
- assertDecisions({ decisions, findings: handoff.findings });
148
-
149
- const decisionsById = new Map(
150
- decisions.map((decision) => [decision.findingId, decision]),
151
- );
152
-
153
- const findings = handoff.findings.map((finding) => {
154
- const decision = decisionsById.get(finding.id);
155
-
156
- if (decision?.decision === FINDING_DECISIONS.REVISE_SPEC) {
157
- return {
158
- ...finding,
159
- decision: {
160
- decision: FINDING_DECISIONS.REVISE_SPEC,
161
- reason: decision.reason,
162
- specChange: decision.specChange,
163
- },
164
- };
165
- }
166
-
167
- if (decision?.decision === FINDING_DECISIONS.REJECT) {
168
- return {
169
- ...finding,
170
- decision: {
171
- decision: FINDING_DECISIONS.REJECT,
172
- reason: decision.reason.trim(),
173
- },
174
- };
175
- }
176
-
177
- return { ...finding, decision: { decision: FINDING_DECISIONS.FIX_CODE } };
178
- });
179
-
180
- const nextHandoff = { ...handoff, findings };
181
-
182
- const nextState = transitionWorkflow({
183
- state,
184
- event: getWorkflowEventByVerifierHandoff(nextHandoff),
185
- });
128
+ const getSavedFindingDecision = (
129
+ decision: FindingDecision | undefined,
130
+ ): SavedFindingDecision => {
131
+ if (decision?.decision === FINDING_DECISIONS.REVISE_SPEC) {
132
+ return {
133
+ decision: FINDING_DECISIONS.REVISE_SPEC,
134
+ reason: decision.reason.trim(),
135
+ specChange: decision.specChange,
136
+ };
137
+ }
186
138
 
187
- assertVerifierHandoff({
188
- handoff: nextHandoff,
189
- specId: state.specId,
190
- });
139
+ if (decision?.decision === FINDING_DECISIONS.REJECT) {
140
+ return {
141
+ decision: FINDING_DECISIONS.REJECT,
142
+ reason: decision.reason.trim(),
143
+ };
144
+ }
191
145
 
192
- return { state: nextState, handoff: nextHandoff };
146
+ return { decision: FINDING_DECISIONS.FIX_CODE };
193
147
  };
194
148
 
195
149
  type ResolveFindingsInput = {
@@ -203,23 +157,35 @@ export const resolveFindings = async ({
203
157
  specId,
204
158
  decisions,
205
159
  }: ResolveFindingsInput): Promise<ResolvedFindings> => {
206
- const currentWorkflow = await readFindingWorkflow({ paths, specId });
160
+ const { state, handoff } = await readFindingWorkflow({ paths, specId });
207
161
 
208
- const workflow = buildFindingWorkflow({
209
- workflow: currentWorkflow,
162
+ const findings = applyFindingDecisions({
210
163
  decisions,
164
+ findings: handoff.findings,
165
+ });
166
+
167
+ const nextHandoff = { ...handoff, findings };
168
+
169
+ const nextState = transitionWorkflow({
170
+ state,
171
+ event: getWorkflowEventByVerifierHandoff(nextHandoff),
172
+ });
173
+
174
+ assertVerifierHandoff({
175
+ handoff: nextHandoff,
176
+ specId: state.specId,
211
177
  });
212
178
 
213
179
  const handoffPath = await paths.getActiveVerifierHandoffPath(specId);
214
- await writeJson({ path: handoffPath, data: workflow.handoff });
180
+ await writeJson({ path: handoffPath, data: nextHandoff });
215
181
  await writeWorkflowState({
216
182
  path: paths.getWorkflowPath(specId),
217
- state: workflow.state,
183
+ state: nextState,
218
184
  });
219
185
 
220
186
  return {
221
- findings: workflow.handoff.findings,
222
- state: workflow.state,
187
+ findings,
188
+ state: nextState,
223
189
  projectRoot: paths.getProjectRoot(),
224
190
  };
225
191
  };
package/templates/spec.md CHANGED
@@ -25,7 +25,7 @@ Leave local implementation choices, test code, fixtures, mocks, and commands to
25
25
 
26
26
  ### Edge cases and error handling
27
27
 
28
- <Describe boundary conditions, failures, and required recovery behavior not already covered above. Cover each required case in the acceptance criteria.>
28
+ <Describe boundary conditions, failures, and required recovery behavior not already covered above. Keep these requirements even when existing tests cover their scenarios.>
29
29
 
30
30
  ## 3. Technical decisions and prototypes
31
31
 
@@ -45,16 +45,20 @@ Leave local implementation choices, test code, fixtures, mocks, and commands to
45
45
 
46
46
  ## 4. Acceptance criteria
47
47
 
48
- <Give each criterion a unique ID and one observable claim. A probe is the scenario used to check that claim. Describe it in plain language.>
48
+ <Inspect existing tests before writing criteria. Omit a criterion only if a test covers the same starting conditions, action, and expected result. Keep criteria when coverage is partial or uncertain. During review, remove criteria already covered by tests. Keep numbering continuous from AC1 and update affected references. Keep required behavior in section 2 even when its criterion is omitted.>
49
+
50
+ <Give each criterion a unique ID, one observable claim, and one coverage label. A probe is the scenario used to check that claim. Describe it in plain language. Use success for a valid action that produces the required result. Use failure for an invalid action that is correctly rejected. Use unchanged when the criterion explicitly checks existing behavior that the change must preserve. The label describes the scenario, not the test outcome. A failure scenario passes when the invalid action is correctly rejected. Do not require all three categories.>
49
51
 
50
52
  ### AC1: <short observable claim>
51
53
 
52
- 1. Probe: <starting conditions and action or observation, without prescribing test implementation>
53
- 2. Expected result: <observable and measurable outcome>
54
- 3. Example: <specific starting conditions, concrete input or action, and exact expected result for this probe, not a restatement of the claim>
54
+ 1. Coverage: <success | failure | unchanged>
55
+ 2. Probe: <starting conditions and action or observation, without prescribing test implementation>
56
+ 3. Expected result: <observable and measurable outcome>
57
+ 4. Example: <specific starting conditions, concrete input or action, and exact expected result for this probe, not a restatement of the claim>
55
58
 
56
59
  <!--
57
60
  Example:
61
+ Coverage: success
58
62
  Probe: Save a change to an item's title, then reopen the item.
59
63
  Expected result: The saved title is still present.
60
64
  Example: An item's title is "Draft". Change it to "Ready", save, and reopen the item. The title is "Ready".