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
|
@@ -10,16 +10,16 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The two gates catch different problems and are intentionally distinct:
|
|
12
12
|
* - The wave gate misses descendants that exist on GitHub but were never
|
|
13
|
-
* in the manifest —
|
|
14
|
-
*
|
|
13
|
+
* in the manifest — mid-sprint additions, recuts that bypassed the
|
|
14
|
+
* dispatcher, or legacy `context::*` artifacts on historical Epics.
|
|
15
15
|
* - The hierarchy gate misses parked follow-ons that live as separate
|
|
16
16
|
* top-level Stories outside the Epic's sub-issue graph.
|
|
17
17
|
*
|
|
18
18
|
* Per ticket type the rule is:
|
|
19
19
|
* - Stories — must be closed.
|
|
20
|
-
* - Auxiliary (context
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* - Auxiliary (legacy `context::*` artifacts) — ignored. Story #4324
|
|
21
|
+
* folded planning content into the Epic body; historical Epics keep
|
|
22
|
+
* their old context tickets, which are reference-only here.
|
|
23
23
|
*
|
|
24
24
|
* **2-tier hierarchy (Story #4041).** Mandrel ships only Epic / Story
|
|
25
25
|
* tickets. `getSubTickets(<storyId>)` returns `[]`; the walk
|
|
@@ -39,7 +39,7 @@ import { parseArgs } from 'node:util';
|
|
|
39
39
|
import { runAsCli } from './lib/cli-utils.js';
|
|
40
40
|
import { resolveConfig } from './lib/config-resolver.js';
|
|
41
41
|
import { Logger } from './lib/Logger.js';
|
|
42
|
-
import {
|
|
42
|
+
import { TYPE_LABELS } from './lib/label-constants.js';
|
|
43
43
|
import { createProvider } from './lib/provider-factory.js';
|
|
44
44
|
import { concurrentMap } from './lib/util/concurrent-map.js';
|
|
45
45
|
|
|
@@ -52,10 +52,10 @@ const SUB_TICKET_FETCH_CONCURRENCY = 4;
|
|
|
52
52
|
function classify(ticket) {
|
|
53
53
|
const labels = ticket.labels ?? [];
|
|
54
54
|
if (labels.includes(TYPE_LABELS.STORY)) return 'story';
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
) {
|
|
55
|
+
// Legacy planning artifacts (pre-#4324 `context::*` tickets on
|
|
56
|
+
// historical Epics) are ignored — they are reference artifacts, not
|
|
57
|
+
// deliverables. New Epics carry planning content on the body itself.
|
|
58
|
+
if (labels.some((l) => typeof l === 'string' && l.startsWith('context::'))) {
|
|
59
59
|
return 'auxiliary';
|
|
60
60
|
}
|
|
61
61
|
return 'other';
|
|
@@ -165,7 +165,7 @@ export async function runHierarchyGate({ epicId, injectedProvider } = {}) {
|
|
|
165
165
|
|
|
166
166
|
const auxNote =
|
|
167
167
|
auxiliaryDeferred > 0
|
|
168
|
-
? ` (${auxiliaryDeferred} auxiliary ticket${auxiliaryDeferred === 1 ? '' : 's'}
|
|
168
|
+
? ` (${auxiliaryDeferred} legacy auxiliary ticket${auxiliaryDeferred === 1 ? '' : 's'} ignored)`
|
|
169
169
|
: '';
|
|
170
170
|
Logger.info(
|
|
171
171
|
`[hierarchy-gate] ✅ All ${descendants.length - auxiliaryDeferred} planned descendant(s) under Epic #${epicId} are closed${auxNote}.`,
|
|
@@ -33,15 +33,16 @@ export class ITicketingProvider {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Fetch the Epic issue with body
|
|
36
|
+
* Fetch the Epic issue with its body — the single planning document
|
|
37
|
+
* (ideation sections plus the folded Tech Spec / Acceptance Table
|
|
38
|
+
* managed sections, Story #4324).
|
|
37
39
|
*
|
|
38
40
|
* @param {number} epicId - GitHub Issue number of the Epic.
|
|
39
41
|
* @returns {Promise<{
|
|
40
42
|
* id: number,
|
|
41
43
|
* title: string,
|
|
42
44
|
* body: string,
|
|
43
|
-
* labels: string[]
|
|
44
|
-
* linkedIssues: { prd: number|null, techSpec: number|null }
|
|
45
|
+
* labels: string[]
|
|
45
46
|
* }>}
|
|
46
47
|
*/
|
|
47
48
|
async getEpic(_epicId) {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Task #2103; workspace-aware extension from Story #2956).
|
|
4
4
|
*
|
|
5
5
|
* Used by `epic-plan-spec.js#buildAuthoringContext` to decide whether the
|
|
6
|
-
* acceptance-
|
|
6
|
+
* acceptance-table section should plan **features-first** Story ordering (a real
|
|
7
7
|
* pending-tag is available, so the features-first Story can ship `.feature`
|
|
8
8
|
* files marked `@pending` / `@skip` ahead of the implementation Stories) or
|
|
9
9
|
* fall back to **dependencies-first** ordering (no pending tag → cannot
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Story #2637 (sibling to #2634 codebase-snapshot, #2635 spec-freshness,
|
|
5
5
|
* #2636 file-assumption gate). The Acceptance Engineer step of
|
|
6
|
-
* `epic-plan-spec-author` currently writes ACs from
|
|
6
|
+
* `epic-plan-spec-author` currently writes ACs from Epic/Tech Spec narrative
|
|
7
7
|
* alone — it never inspects the consumer project's existing `.feature`
|
|
8
8
|
* files. Planned ACs frequently duplicate scenarios that already exist or
|
|
9
9
|
* re-specify behaviour the codebase already proves; the duplication is
|
|
@@ -60,8 +60,6 @@ export function parseSprintArgs(args = process.argv) {
|
|
|
60
60
|
'no-full-scope-crap': { type: 'boolean', default: false },
|
|
61
61
|
executor: { type: 'string' },
|
|
62
62
|
cwd: { type: 'string' },
|
|
63
|
-
prd: { type: 'string' },
|
|
64
|
-
'tech-spec': { type: 'string' },
|
|
65
63
|
'recut-of': { type: 'string' },
|
|
66
64
|
resume: { type: 'boolean', default: false },
|
|
67
65
|
restart: { type: 'boolean', default: false },
|
|
@@ -88,10 +86,8 @@ export function parseSprintArgs(args = process.argv) {
|
|
|
88
86
|
process.env.AGENT_WORKTREE_ROOT ||
|
|
89
87
|
null,
|
|
90
88
|
recutOf: parseTicketId(values['recut-of']),
|
|
91
|
-
// Story #4253: pre-resolved Epic
|
|
89
|
+
// Story #4253: pre-resolved Epic linkage threaded by the /deliver
|
|
92
90
|
// fan-out so `story-init.js` can skip the per-Story `getEpic` round-trip.
|
|
93
|
-
prdId: parseTicketId(values.prd),
|
|
94
|
-
techSpecId: parseTicketId(values['tech-spec']),
|
|
95
91
|
resume: values.resume ?? false,
|
|
96
92
|
restart: values.restart ?? false,
|
|
97
93
|
noEvidence: values['no-evidence'] ?? false,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* codebase-snapshot.js — Bounded structural view of the consumer repo.
|
|
3
3
|
*
|
|
4
4
|
* Story #2634 (sibling to #2635 spec-freshness). `/plan` Phase 7
|
|
5
|
-
* authors
|
|
5
|
+
* authors the Tech Spec from documentation alone — `architecture.md`,
|
|
6
6
|
* `data-dictionary.md`, `decisions.md`, `patterns.md`. When those docs
|
|
7
7
|
* drift from the real source tree, the Architect persona cites modules
|
|
8
8
|
* and paths that no longer exist, and the mismatch only surfaces at
|
|
@@ -232,7 +232,10 @@ const PREFIX_MEANINGS = Object.freeze([
|
|
|
232
232
|
'planning.taskSizing',
|
|
233
233
|
'Story-sizing threshold for the decompose validator.',
|
|
234
234
|
],
|
|
235
|
-
[
|
|
235
|
+
[
|
|
236
|
+
'qa.environments',
|
|
237
|
+
'QA harness deployment target (baseUrl, per-environment sign-in seam, allowWrites gate).',
|
|
238
|
+
],
|
|
236
239
|
['qa.personas', 'QA harness persona / credential mapping.'],
|
|
237
240
|
]);
|
|
238
241
|
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Layout:
|
|
12
12
|
* temp/epic-<eid>/
|
|
13
|
-
* ├─ prd.md
|
|
14
13
|
* ├─ techspec.md
|
|
15
14
|
* ├─ manifest.md (dispatch manifest)
|
|
16
15
|
* ├─ retro.md (mirror of GitHub retro at Epic close)
|
|
@@ -228,7 +227,7 @@ export function epicTempDir(eid, config) {
|
|
|
228
227
|
* location instead of being dropped on the floor.
|
|
229
228
|
*
|
|
230
229
|
* Story #2940 introduced the intermediate `stories/` segment so that
|
|
231
|
-
* per-Epic top-level artifacts (`
|
|
230
|
+
* per-Epic top-level artifacts (`techspec.md`, `manifest.md`,
|
|
232
231
|
* `retro.md`, `lifecycle.ndjson`, `baselines/`, `checkpoints/`) are
|
|
233
232
|
* visually and structurally separated from the per-Story siblings.
|
|
234
233
|
*
|
|
@@ -291,8 +290,6 @@ export function storyArtifactPath(eid, sid, name, config) {
|
|
|
291
290
|
|
|
292
291
|
// --- Canonical Epic-level filenames (Tech Spec #1032 §tempRoot) ---
|
|
293
292
|
|
|
294
|
-
export const epicPrdPath = (eid, config) =>
|
|
295
|
-
epicArtifactPath(eid, 'prd.md', config);
|
|
296
293
|
export const epicTechSpecPath = (eid, config) =>
|
|
297
294
|
epicArtifactPath(eid, 'techspec.md', config);
|
|
298
295
|
export const epicManifestPath = (eid, config) =>
|
|
@@ -263,6 +263,11 @@ const TASK_SIZING_SCHEMA = {
|
|
|
263
263
|
hardFiles: { type: 'integer', minimum: 1 },
|
|
264
264
|
maxAcceptance: { type: 'integer', minimum: 1 },
|
|
265
265
|
softAcceptanceCount: { type: 'integer', minimum: 1 },
|
|
266
|
+
// Under-size (merge-candidate) thresholds (Story #4312). A Story whose
|
|
267
|
+
// footprint is at or below BOTH ceilings and that carries a `depends_on`
|
|
268
|
+
// edge to a sibling trips the advisory `merge-candidate` soft finding.
|
|
269
|
+
mergeCandidateMaxFiles: { type: 'integer', minimum: 1 },
|
|
270
|
+
mergeCandidateMaxAcceptance: { type: 'integer', minimum: 1 },
|
|
266
271
|
},
|
|
267
272
|
additionalProperties: false,
|
|
268
273
|
};
|
|
@@ -401,12 +406,36 @@ const QA_PERSONAS_SCHEMA = {
|
|
|
401
406
|
],
|
|
402
407
|
};
|
|
403
408
|
|
|
409
|
+
// `environments` is the environment-keyed contract (Epic #4326, Story #4327).
|
|
410
|
+
// It replaces the retired top-level single `signInSeam` shape: each named
|
|
411
|
+
// environment carries its own `baseUrl`, its own per-environment `signInSeam`
|
|
412
|
+
// (reusing the same url-template/skill union), and an optional `allowWrites`
|
|
413
|
+
// gate. Downstream, `resolveQaEnvironment` selects one environment per
|
|
414
|
+
// invocation by name or by raw-URL origin match against `baseUrl`. The map
|
|
415
|
+
// must carry at least one environment. This is a hard cutover — there is no
|
|
416
|
+
// top-level `signInSeam` acceptance branch (see
|
|
417
|
+
// `.agents/rules/git-conventions.md` § Contract Cutovers).
|
|
418
|
+
const QA_ENVIRONMENTS_SCHEMA = {
|
|
419
|
+
type: 'object',
|
|
420
|
+
minProperties: 1,
|
|
421
|
+
additionalProperties: {
|
|
422
|
+
type: 'object',
|
|
423
|
+
properties: {
|
|
424
|
+
baseUrl: { ...SAFE_STRING, minLength: 1 },
|
|
425
|
+
signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
|
|
426
|
+
allowWrites: { type: 'boolean' },
|
|
427
|
+
},
|
|
428
|
+
required: ['baseUrl', 'signInSeam'],
|
|
429
|
+
additionalProperties: false,
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
|
|
404
433
|
export const QA_SCHEMA = {
|
|
405
434
|
type: 'object',
|
|
406
435
|
properties: {
|
|
407
436
|
featureRoot: { ...SAFE_STRING, minLength: 1 },
|
|
408
437
|
fixturesManifest: { ...SAFE_STRING, minLength: 1 },
|
|
409
|
-
|
|
438
|
+
environments: QA_ENVIRONMENTS_SCHEMA,
|
|
410
439
|
personas: QA_PERSONAS_SCHEMA,
|
|
411
440
|
consoleAllowlist: {
|
|
412
441
|
type: 'array',
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* epic-body-sections.js — marker-delimited managed sections of the Epic body.
|
|
3
|
+
*
|
|
4
|
+
* Story #4324 retired the `context::tech-spec` / `context::acceptance-spec`
|
|
5
|
+
* ticket classes: the Epic body is now the single planning document. The
|
|
6
|
+
* Tech Spec (opening with `## Delivery Slicing`, per #4316) and the
|
|
7
|
+
* Acceptance Spec's AC-ID table (`## Acceptance Table`, Outcomes keyed to
|
|
8
|
+
* Epic AC bullets per #4315) land as **managed sections** of the Epic body,
|
|
9
|
+
* delimited by invisible HTML comment markers so each writer can update
|
|
10
|
+
* *only its own region* without rewriting the rest of the body.
|
|
11
|
+
*
|
|
12
|
+
* Section-scoped writes are the load-bearing contract (extending the
|
|
13
|
+
* single-writer discipline #4303 established for the body trailer):
|
|
14
|
+
*
|
|
15
|
+
* - The Phase 7 spec persist path (`epic-plan-spec/phases/plan-epic.js`)
|
|
16
|
+
* upserts the `techSpec` and `acceptanceTable` regions.
|
|
17
|
+
* - The close-time acceptance reconciler
|
|
18
|
+
* (`acceptance-spec-reconciler.js`) reads and rewrites the
|
|
19
|
+
* `acceptanceTable` region only (verification dispositions).
|
|
20
|
+
*
|
|
21
|
+
* Everything outside a managed region is byte-preserved by every helper in
|
|
22
|
+
* this module. Markers are chosen so GitHub renders nothing for them; the
|
|
23
|
+
* human-visible headings (`## Delivery Slicing`, `## Acceptance Table`)
|
|
24
|
+
* live INSIDE the regions as ordinary content.
|
|
25
|
+
*
|
|
26
|
+
* Pure ESM, no I/O.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Managed-region descriptors. `start`/`end` are the literal marker lines;
|
|
31
|
+
* `label` is the human name used in log lines and error messages.
|
|
32
|
+
*
|
|
33
|
+
* @type {Readonly<Record<'techSpec'|'acceptanceTable', { start: string, end: string, label: string }>>}
|
|
34
|
+
*/
|
|
35
|
+
export const EPIC_BODY_SECTIONS = Object.freeze({
|
|
36
|
+
techSpec: Object.freeze({
|
|
37
|
+
start: '<!-- mandrel:tech-spec:start -->',
|
|
38
|
+
end: '<!-- mandrel:tech-spec:end -->',
|
|
39
|
+
label: 'Tech Spec',
|
|
40
|
+
}),
|
|
41
|
+
acceptanceTable: Object.freeze({
|
|
42
|
+
start: '<!-- mandrel:acceptance-table:start -->',
|
|
43
|
+
end: '<!-- mandrel:acceptance-table:end -->',
|
|
44
|
+
label: 'Acceptance Table',
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Canonical heading the acceptance-table region opens with. Distinct from
|
|
50
|
+
* the Epic's ideation `## Acceptance Criteria` bullets (which remain the
|
|
51
|
+
* SSOT for *what* the table verifies — the table anchors to those bullets).
|
|
52
|
+
*/
|
|
53
|
+
export const ACCEPTANCE_TABLE_HEADING = '## Acceptance Table';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Regex matching the Tech Spec's required opening heading (same variants
|
|
57
|
+
* `spec-section-validator.js` accepts).
|
|
58
|
+
*/
|
|
59
|
+
const DELIVERY_SLICING_RE = /^##\s+(?:Delivery\s+)?Slicing\s*$/im;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {'techSpec'|'acceptanceTable'} kind
|
|
63
|
+
* @returns {{ start: string, end: string, label: string }}
|
|
64
|
+
*/
|
|
65
|
+
function descriptor(kind) {
|
|
66
|
+
const d = EPIC_BODY_SECTIONS[kind];
|
|
67
|
+
if (!d) {
|
|
68
|
+
throw new TypeError(
|
|
69
|
+
`epic-body-sections: unknown section kind "${kind}" (expected ${Object.keys(EPIC_BODY_SECTIONS).join(' | ')})`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return d;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Locate a managed region. Returns `null` when either marker is absent or
|
|
77
|
+
* the end marker precedes the start marker (malformed body — treated as
|
|
78
|
+
* absent so a writer re-appends a well-formed region rather than
|
|
79
|
+
* corrupting the body further).
|
|
80
|
+
*
|
|
81
|
+
* @param {string} body
|
|
82
|
+
* @param {'techSpec'|'acceptanceTable'} kind
|
|
83
|
+
* @returns {{ startIdx: number, contentStart: number, contentEnd: number, endIdx: number }|null}
|
|
84
|
+
*/
|
|
85
|
+
function locate(body, kind) {
|
|
86
|
+
const { start, end } = descriptor(kind);
|
|
87
|
+
if (typeof body !== 'string' || body.length === 0) return null;
|
|
88
|
+
const startIdx = body.indexOf(start);
|
|
89
|
+
if (startIdx === -1) return null;
|
|
90
|
+
const contentStart = startIdx + start.length;
|
|
91
|
+
const endIdx = body.indexOf(end, contentStart);
|
|
92
|
+
if (endIdx === -1) return null;
|
|
93
|
+
return { startIdx, contentStart, contentEnd: endIdx, endIdx };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True when the body carries a well-formed managed region of `kind`.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} body
|
|
100
|
+
* @param {'techSpec'|'acceptanceTable'} kind
|
|
101
|
+
* @returns {boolean}
|
|
102
|
+
*/
|
|
103
|
+
export function hasEpicSection(body, kind) {
|
|
104
|
+
return locate(body, kind) !== null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract a managed region's content (between the markers, trimmed).
|
|
109
|
+
* Returns `null` when the region is absent.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} body
|
|
112
|
+
* @param {'techSpec'|'acceptanceTable'} kind
|
|
113
|
+
* @returns {string|null}
|
|
114
|
+
*/
|
|
115
|
+
export function extractEpicSection(body, kind) {
|
|
116
|
+
const loc = locate(body, kind);
|
|
117
|
+
if (!loc) return null;
|
|
118
|
+
return body.slice(loc.contentStart, loc.contentEnd).trim();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Insert or replace a managed region, preserving every byte outside it.
|
|
123
|
+
*
|
|
124
|
+
* When the region exists, only the content between the markers is
|
|
125
|
+
* replaced. When absent, the region is appended at the end of the body
|
|
126
|
+
* (trailing whitespace normalised to a single blank-line separator). The
|
|
127
|
+
* `techSpec` region is appended before an existing `acceptanceTable`
|
|
128
|
+
* region so the rendered document keeps its canonical order
|
|
129
|
+
* (ideation sections → Tech Spec → Acceptance Table).
|
|
130
|
+
*
|
|
131
|
+
* @param {string} body
|
|
132
|
+
* @param {'techSpec'|'acceptanceTable'} kind
|
|
133
|
+
* @param {string} content Section content (headings included).
|
|
134
|
+
* @returns {string}
|
|
135
|
+
*/
|
|
136
|
+
export function upsertEpicSection(body, kind, content) {
|
|
137
|
+
const { start, end } = descriptor(kind);
|
|
138
|
+
const safeBody = typeof body === 'string' ? body : '';
|
|
139
|
+
const trimmedContent = typeof content === 'string' ? content.trim() : '';
|
|
140
|
+
const region = `${start}\n\n${trimmedContent}\n\n${end}`;
|
|
141
|
+
|
|
142
|
+
const loc = locate(safeBody, kind);
|
|
143
|
+
if (loc) {
|
|
144
|
+
return (
|
|
145
|
+
safeBody.slice(0, loc.startIdx) +
|
|
146
|
+
region +
|
|
147
|
+
safeBody.slice(loc.endIdx + end.length)
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Keep canonical order when appending: the Tech Spec region goes before
|
|
152
|
+
// an already-present acceptance-table region.
|
|
153
|
+
if (kind === 'techSpec') {
|
|
154
|
+
const acceptanceLoc = locate(safeBody, 'acceptanceTable');
|
|
155
|
+
if (acceptanceLoc) {
|
|
156
|
+
const head = safeBody
|
|
157
|
+
.slice(0, acceptanceLoc.startIdx)
|
|
158
|
+
.replace(/\s+$/, '');
|
|
159
|
+
const tail = safeBody.slice(acceptanceLoc.startIdx);
|
|
160
|
+
return `${head}\n\n${region}\n\n${tail}`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const trimmedBody = safeBody.replace(/\s+$/, '');
|
|
165
|
+
return trimmedBody.length > 0
|
|
166
|
+
? `${trimmedBody}\n\n${region}\n`
|
|
167
|
+
: `${region}\n`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Remove a managed region (markers and content). Byte-preserving outside
|
|
172
|
+
* the region; collapses the surrounding blank lines the writer added.
|
|
173
|
+
* No-op when the region is absent.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} body
|
|
176
|
+
* @param {'techSpec'|'acceptanceTable'} kind
|
|
177
|
+
* @returns {string}
|
|
178
|
+
*/
|
|
179
|
+
export function stripEpicSection(body, kind) {
|
|
180
|
+
const { end } = descriptor(kind);
|
|
181
|
+
const loc = locate(body, kind);
|
|
182
|
+
if (!loc) return typeof body === 'string' ? body : '';
|
|
183
|
+
const before = body.slice(0, loc.startIdx).replace(/\n+$/, '\n');
|
|
184
|
+
const after = body.slice(loc.endIdx + end.length).replace(/^\n+/, '\n');
|
|
185
|
+
return (before + after).replace(/\n{3,}/g, '\n\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Re-plan / decompose detection: true when the Epic body carries folded
|
|
190
|
+
* Tech Spec content — the managed region, or (defence in depth for a
|
|
191
|
+
* hand-authored body) a bare `## Delivery Slicing` heading.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} body
|
|
194
|
+
* @returns {boolean}
|
|
195
|
+
*/
|
|
196
|
+
export function hasTechSpecContent(body) {
|
|
197
|
+
if (hasEpicSection(body, 'techSpec')) return true;
|
|
198
|
+
return typeof body === 'string' && DELIVERY_SLICING_RE.test(body);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `##` headings dropped from the delivery-hydrated Epic body. These are
|
|
203
|
+
* ideation / authoring / close-machinery sections a story agent never acts
|
|
204
|
+
* on: keeping them out of the per-Story prompt trims token cost without
|
|
205
|
+
* losing any binding context (the Story carries its own inline
|
|
206
|
+
* acceptance[] / verify[]).
|
|
207
|
+
*
|
|
208
|
+
* @type {ReadonlySet<string>}
|
|
209
|
+
*/
|
|
210
|
+
const DELIVERY_DROP_HEADINGS = new Set([
|
|
211
|
+
'context',
|
|
212
|
+
'scope',
|
|
213
|
+
'acceptance criteria',
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Slice an Epic body down to the sections a delivery story agent acts on.
|
|
218
|
+
*
|
|
219
|
+
* KEEP: the Epic title / preamble before the first `##`, `## Goal`,
|
|
220
|
+
* `## Non-Goals`, `## User Stories`, the `techSpec` managed region, and —
|
|
221
|
+
* fail-open — any unknown / operator-authored `##` section not in the drop
|
|
222
|
+
* list. DROP: `## Context`, `## Scope`, `## Acceptance Criteria`, and the
|
|
223
|
+
* `acceptanceTable` managed region.
|
|
224
|
+
*
|
|
225
|
+
* The `techSpec` region is located by its markers (not by heading
|
|
226
|
+
* boundaries) so its inner `## Delivery Slicing` heading is preserved
|
|
227
|
+
* verbatim and never mistaken for a plain droppable section. Plain `##`
|
|
228
|
+
* sections outside the managed regions are sliced by heading boundaries.
|
|
229
|
+
*
|
|
230
|
+
* Fail-open is load-bearing: any heading not explicitly in the drop set is
|
|
231
|
+
* kept, so operator-authored content is never silently lost.
|
|
232
|
+
*
|
|
233
|
+
* @param {string} body
|
|
234
|
+
* @returns {string}
|
|
235
|
+
*/
|
|
236
|
+
export function sliceEpicBodyForDelivery(body) {
|
|
237
|
+
if (typeof body !== 'string' || body.length === 0) return '';
|
|
238
|
+
|
|
239
|
+
// 1. Drop the acceptance-table managed region outright (authoring/close
|
|
240
|
+
// machinery, never delivery context).
|
|
241
|
+
let working = stripEpicSection(body, 'acceptanceTable');
|
|
242
|
+
|
|
243
|
+
// 2. Protect the techSpec managed region from heading-boundary slicing by
|
|
244
|
+
// lifting it out behind an opaque placeholder, then restoring it after
|
|
245
|
+
// the plain-section pass. Its inner `## Delivery Slicing` heading must
|
|
246
|
+
// survive verbatim.
|
|
247
|
+
const techLoc = locate(working, 'techSpec');
|
|
248
|
+
let techRegion = null;
|
|
249
|
+
const PLACEHOLDER = 'MANDREL_TECH_SPEC_PLACEHOLDER';
|
|
250
|
+
if (techLoc) {
|
|
251
|
+
const { end } = descriptor('techSpec');
|
|
252
|
+
techRegion = working.slice(techLoc.startIdx, techLoc.endIdx + end.length);
|
|
253
|
+
working =
|
|
254
|
+
working.slice(0, techLoc.startIdx) +
|
|
255
|
+
PLACEHOLDER +
|
|
256
|
+
working.slice(techLoc.endIdx + end.length);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 3. Slice plain `##` sections by heading boundaries, dropping only the
|
|
260
|
+
// known ideation/authoring headings. The preamble before the first
|
|
261
|
+
// `##` (Epic title / lede) is always kept.
|
|
262
|
+
const kept = [];
|
|
263
|
+
let dropping = false;
|
|
264
|
+
for (const line of working.split('\n')) {
|
|
265
|
+
const headingMatch = line.match(/^##\s+(.*?)\s*$/);
|
|
266
|
+
if (headingMatch) {
|
|
267
|
+
dropping = DELIVERY_DROP_HEADINGS.has(
|
|
268
|
+
headingMatch[1].trim().toLowerCase(),
|
|
269
|
+
);
|
|
270
|
+
if (dropping) continue;
|
|
271
|
+
}
|
|
272
|
+
if (dropping) continue;
|
|
273
|
+
kept.push(line);
|
|
274
|
+
}
|
|
275
|
+
working = kept.join('\n');
|
|
276
|
+
|
|
277
|
+
// 4. Restore the techSpec region in place.
|
|
278
|
+
if (techRegion !== null) {
|
|
279
|
+
working = working.replace(PLACEHOLDER, () => techRegion);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 5. Normalise the blank-line runs left by the drops.
|
|
283
|
+
return working
|
|
284
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
285
|
+
.replace(/^\n+/, '')
|
|
286
|
+
.trimEnd();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Strip the retired machine-managed `## Planning Artifacts` checklist from
|
|
291
|
+
* an Epic body (the section that linked the now-retired context tickets).
|
|
292
|
+
* The slice ends at the next `## ` heading, a managed-region marker, or
|
|
293
|
+
* EOF. Historical bodies without the section pass through untouched.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} body
|
|
296
|
+
* @returns {string}
|
|
297
|
+
*/
|
|
298
|
+
export function stripPlanningArtifactsSection(body) {
|
|
299
|
+
if (typeof body !== 'string' || body.length === 0) return '';
|
|
300
|
+
const headingMatch = body.match(/^##\s+Planning Artifacts[^\n]*$/m);
|
|
301
|
+
if (!headingMatch || typeof headingMatch.index !== 'number') return body;
|
|
302
|
+
const start = headingMatch.index;
|
|
303
|
+
const afterHeading = start + headingMatch[0].length;
|
|
304
|
+
const rest = body.slice(afterHeading);
|
|
305
|
+
const boundary = rest.search(/^(?:##\s|<!-- mandrel:)/m);
|
|
306
|
+
const end = boundary === -1 ? body.length : afterHeading + boundary;
|
|
307
|
+
const before = body.slice(0, start).replace(/\n+$/, '\n');
|
|
308
|
+
const after = body.slice(end);
|
|
309
|
+
return (before + after).replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '');
|
|
310
|
+
}
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
* `## Work Breakdown`
|
|
29
29
|
* - `## Acceptance Criteria`, `## Acceptance`, `## AC`
|
|
30
30
|
*
|
|
31
|
+
* Story #4324 folded the Tech Spec and Acceptance Spec into the Epic body
|
|
32
|
+
* as managed sections (`## Delivery Slicing`-led spec sections and the
|
|
33
|
+
* `## Acceptance Table`). The gate *recognises* those planning sections —
|
|
34
|
+
* reporting their presence under `planningSections[]` so a post-fold Epic
|
|
35
|
+
* body scores exactly as its ideation content deserves — but they never
|
|
36
|
+
* count toward (or against) the five-ideation-section verdict: Phase 6
|
|
37
|
+
* runs before Phase 7 authors them, and a re-planned Epic that already
|
|
38
|
+
* carries them must not be penalised or auto-passed by their presence.
|
|
39
|
+
*
|
|
31
40
|
* Pure ESM, no I/O.
|
|
32
41
|
*/
|
|
33
42
|
|
|
@@ -54,6 +63,26 @@ export const SECTION_NAMES = Object.freeze([
|
|
|
54
63
|
'acceptanceCriteria',
|
|
55
64
|
]);
|
|
56
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Post-fold planning sections (Story #4324) — recognised and reported,
|
|
68
|
+
* never scored. `deliverySlicing` accepts the same heading variants as
|
|
69
|
+
* `spec-section-validator.js`.
|
|
70
|
+
*/
|
|
71
|
+
const PLANNING_SECTION_RE = {
|
|
72
|
+
deliverySlicing: /^##\s+(?:Delivery\s+)?Slicing\s*$/im,
|
|
73
|
+
acceptanceTable: /^##\s+Acceptance\s+Table\s*$/im,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Names of the recognised (unscored) planning sections, in document order.
|
|
78
|
+
* Module-private: consumers read the reported `planningSections[]` rows on
|
|
79
|
+
* the scoreEpicBody result rather than importing the name list.
|
|
80
|
+
*/
|
|
81
|
+
const PLANNING_SECTION_NAMES = Object.freeze([
|
|
82
|
+
'deliverySlicing',
|
|
83
|
+
'acceptanceTable',
|
|
84
|
+
]);
|
|
85
|
+
|
|
57
86
|
const CLEAR_THRESHOLD = 4;
|
|
58
87
|
const PLACEHOLDER_PATTERN = /^_\(not\s+specified\)_$/i;
|
|
59
88
|
|
|
@@ -80,6 +109,7 @@ function classify(content) {
|
|
|
80
109
|
* verdict: 'clear' | 'needs-refinement',
|
|
81
110
|
* sections: Array<{ name: string, status: 'present' | 'placeholder' | 'missing' }>,
|
|
82
111
|
* missingOrPlaceholder: string[],
|
|
112
|
+
* planningSections: Array<{ name: string, status: 'present' | 'missing' }>,
|
|
83
113
|
* }}
|
|
84
114
|
*/
|
|
85
115
|
const REQUIRED_SECTION = 'acceptanceCriteria';
|
|
@@ -138,5 +168,12 @@ export function scoreEpicBody({ body } = {}) {
|
|
|
138
168
|
? 'clear'
|
|
139
169
|
: 'needs-refinement';
|
|
140
170
|
|
|
141
|
-
|
|
171
|
+
// Informational only: presence of the post-fold planning sections
|
|
172
|
+
// (Story #4324). Never feeds the verdict.
|
|
173
|
+
const planningSections = PLANNING_SECTION_NAMES.map((name) => ({
|
|
174
|
+
name,
|
|
175
|
+
status: PLANNING_SECTION_RE[name].test(source) ? 'present' : 'missing',
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
return { verdict, sections, missingOrPlaceholder, planningSections };
|
|
142
179
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* using the canonical template at `.agents/templates/epic-from-idea.md`.
|
|
6
6
|
* Phase 4: open the GitHub Issue via an injected provider with the
|
|
7
7
|
* `type::epic` label only — no `state::draft` (the Epic carries only
|
|
8
|
-
* `type::epic` until
|
|
8
|
+
* `type::epic` until spec authoring writes `agent::review-spec`).
|
|
9
9
|
*
|
|
10
10
|
* The template is parsed from a string the caller has already loaded
|
|
11
11
|
* (typically via `fs.readFile`). The renderer is pure — no I/O — and
|
|
@@ -30,13 +30,23 @@ const SECTION_RE = {
|
|
|
30
30
|
/^##\s+(?:Non[\s-]?Goals|Out\s+of\s+Scope|Not\s+Doing(?:\s+\(and\s+Why\))?)\s*$/im,
|
|
31
31
|
scope:
|
|
32
32
|
/^##\s+(?:MVP\s+|Proposed\s+)?Scope(?:\s+\([^)]+\))?\s*$|^##\s+Work\s+Breakdown\s*$/im,
|
|
33
|
+
// Story #4314 — the PRD's one novel section (User Stories) now folds into
|
|
34
|
+
// the Epic body as a canonical section.
|
|
35
|
+
userStories: /^##\s+(?:User\s+Stories|Stories|Personas?\s+&\s+Stories)\s*$/im,
|
|
33
36
|
acceptanceCriteria: /^##\s+(?:Acceptance(?:\s+Criteria)?|AC)\s*$/im,
|
|
34
37
|
};
|
|
35
38
|
|
|
36
|
-
const ORDER = [
|
|
39
|
+
const ORDER = [
|
|
40
|
+
'context',
|
|
41
|
+
'goal',
|
|
42
|
+
'nonGoals',
|
|
43
|
+
'scope',
|
|
44
|
+
'userStories',
|
|
45
|
+
'acceptanceCriteria',
|
|
46
|
+
];
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
|
-
* Extract the
|
|
49
|
+
* Extract the six canonical sections from an idea-refinement one-pager.
|
|
40
50
|
*
|
|
41
51
|
* @param {string} onePager - Markdown produced by Phase 3 of the
|
|
42
52
|
* `idea-refinement` skill.
|
|
@@ -46,6 +56,7 @@ const ORDER = ['context', 'goal', 'nonGoals', 'scope', 'acceptanceCriteria'];
|
|
|
46
56
|
* goal: string,
|
|
47
57
|
* nonGoals: string,
|
|
48
58
|
* scope: string,
|
|
59
|
+
* userStories: string,
|
|
49
60
|
* acceptanceCriteria: string,
|
|
50
61
|
* }}
|
|
51
62
|
*/
|
|
@@ -72,6 +83,7 @@ export function parseOnePager(onePager) {
|
|
|
72
83
|
goal: '',
|
|
73
84
|
nonGoals: '',
|
|
74
85
|
scope: '',
|
|
86
|
+
userStories: '',
|
|
75
87
|
acceptanceCriteria: '',
|
|
76
88
|
};
|
|
77
89
|
|
|
@@ -196,8 +196,8 @@ function clusterToFinding(cluster) {
|
|
|
196
196
|
/**
|
|
197
197
|
* Build the `routedTo` link the schema stamps onto a promoted ledger item.
|
|
198
198
|
*
|
|
199
|
-
* The `routedTo.url` field is `minLength: 1` in
|
|
200
|
-
* and
|
|
199
|
+
* The `routedTo.url` field is `minLength: 1` in `qa-ledger.schema.json`,
|
|
200
|
+
* and the search/create port contract requires a
|
|
201
201
|
* routed issue to carry its canonical URL. So rather than silently stamp an
|
|
202
202
|
* empty string (which would persist a schema-invalid ledger item), this guards
|
|
203
203
|
* the url and throws when it is absent or blank (Story #3816, AC #4).
|
|
@@ -213,7 +213,7 @@ function routedToLink(issue, kind) {
|
|
|
213
213
|
throw new Error(
|
|
214
214
|
`promoteFindings: routed issue #${issue?.number ?? '?'} is missing a url; ` +
|
|
215
215
|
'the search/create port contract requires a non-empty url ' +
|
|
216
|
-
'(routedTo.url is minLength:1 in the qa-
|
|
216
|
+
'(routedTo.url is minLength:1 in the qa-ledger schema)',
|
|
217
217
|
);
|
|
218
218
|
}
|
|
219
219
|
return {
|