mandrel 2.38.0 → 2.40.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 +51 -11
- package/.agents/agents/auditor.md +5 -0
- package/.agents/docs/SDLC.md +21 -12
- package/.agents/docs/agentrc-reference.json +1 -4
- package/.agents/docs/configuration.md +2 -2
- package/.agents/instructions.md +17 -16
- package/.agents/schemas/agentrc.schema.json +6 -7
- package/.agents/scripts/audit-to-stories.js +510 -66
- package/.agents/scripts/generate-skills-index.js +158 -75
- package/.agents/scripts/lib/audit-to-stories/epic-grouping-directive.js +39 -0
- package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +290 -0
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +94 -3
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +10 -0
- package/.agents/scripts/lib/changed-files.js +100 -9
- package/.agents/scripts/lib/config-settings-schema.js +25 -7
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/label-constants.js +18 -0
- package/.agents/scripts/lib/label-taxonomy.js +18 -5
- package/.agents/scripts/lib/orchestration/epic-container.js +186 -0
- package/.agents/scripts/lib/orchestration/epic-expansion.js +148 -0
- package/.agents/scripts/lib/orchestration/plan-persist/epic-ops.js +320 -0
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +18 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +130 -1
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
- package/.agents/scripts/lib/skills/skills-index.js +168 -0
- package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
- package/.agents/scripts/plan-persist.js +39 -1
- package/.agents/scripts/providers/github/sub-issue-add.js +218 -0
- package/.agents/scripts/quality-preview.js +50 -9
- package/.agents/scripts/resolve-stories.js +42 -2
- package/.agents/scripts/validate-skills.js +53 -66
- package/.agents/templates/docs/audit-sweep-runbook.md +169 -0
- package/.agents/workflows/audit-to-stories.md +85 -7
- package/.agents/workflows/helpers/audit-lens-core.md +24 -4
- package/.agents/workflows/helpers/deliver-reference.md +8 -0
- package/.agents/workflows/helpers/plan-reference.md +28 -0
- package/.agents/workflows/mandrel-deliver.md +47 -43
- package/.agents/workflows/mandrel-plan.md +44 -38
- package/.agents/workflows/qa-run.md +13 -5
- package/docs/CHANGELOG.md +28 -0
- package/package.json +1 -1
|
@@ -20,7 +20,10 @@ import path from 'node:path';
|
|
|
20
20
|
import { normalizeSeverity } from '../findings/severity.js';
|
|
21
21
|
|
|
22
22
|
const KEY_LINE = /^\s*-\s*\*\*([^:*]+):\*\*\s*(.*)$/;
|
|
23
|
-
const HEADING_FINDING =
|
|
23
|
+
const HEADING_FINDING = /^(#{3,4})\s+(.+?)\s*$/;
|
|
24
|
+
const SEVERITY_KEY_LINE = /^\s*-\s*\*\*(?:severity|impact)\s*:\*\*/i;
|
|
25
|
+
const TALLY_LINE =
|
|
26
|
+
/severity\s+tally\s*:?\**\s*critical\s+(\d+)\s*\/\s*high\s+(\d+)\s*\/\s*medium\s+(\d+)\s*\/\s*low\s+(\d+)/i;
|
|
24
27
|
const HEADING_SECTION = /^##\s+(.+?)\s*$/;
|
|
25
28
|
const PATH_HINT =
|
|
26
29
|
/(?<![\w/])([A-Za-z0-9_./\\@-]+\.(?:js|ts|tsx|jsx|mjs|cjs|md|json|yaml|yml|css|scss|html|py|go|rs|java|kt|rb|sh|ps1|tf|env))(?![\w])/g;
|
|
@@ -238,7 +241,11 @@ function splitFindingBlocks(reportText) {
|
|
|
238
241
|
const findingMatch = HEADING_FINDING.exec(line);
|
|
239
242
|
if (findingMatch) {
|
|
240
243
|
if (current) blocks.push(current);
|
|
241
|
-
current = {
|
|
244
|
+
current = {
|
|
245
|
+
level: findingMatch[1].length,
|
|
246
|
+
title: findingMatch[2].trim(),
|
|
247
|
+
bodyLines: [],
|
|
248
|
+
};
|
|
242
249
|
continue;
|
|
243
250
|
}
|
|
244
251
|
|
|
@@ -249,6 +256,88 @@ function splitFindingBlocks(reportText) {
|
|
|
249
256
|
return blocks;
|
|
250
257
|
}
|
|
251
258
|
|
|
259
|
+
/**
|
|
260
|
+
* Does this block carry the axis line that makes it a finding rather than a
|
|
261
|
+
* section header? The skeleton mandates `Severity` (or its `Impact` alias), so
|
|
262
|
+
* its absence is the signal that a heading is organisational.
|
|
263
|
+
*
|
|
264
|
+
* @param {{ bodyLines: string[] }} block
|
|
265
|
+
* @returns {boolean}
|
|
266
|
+
*/
|
|
267
|
+
function carriesSeverity(block) {
|
|
268
|
+
return block.bodyLines.some((line) => SEVERITY_KEY_LINE.test(line));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Resolve `###` headings that are **grouping headers** rather than findings.
|
|
273
|
+
*
|
|
274
|
+
* Several lenses nest their findings one level deeper — a `###` per dimension
|
|
275
|
+
* (`### Perceivable`), each holding `####` finding blocks. Read flat, that
|
|
276
|
+
* report parsed as one severity-less finding per dimension with no files and
|
|
277
|
+
* no recommendation, and `--auto` filed those empties (Story #5144). The rule
|
|
278
|
+
* this applies: a `###` heading that carries no `Severity:`/`Impact:` line and
|
|
279
|
+
* is followed by `####` headings is a grouping header — its `####` children
|
|
280
|
+
* are emitted as findings and the header itself never is.
|
|
281
|
+
*
|
|
282
|
+
* A `###` heading that DOES carry the axis line keeps the previous behaviour:
|
|
283
|
+
* its `####` sub-sections fold back into its own body rather than splitting
|
|
284
|
+
* into phantom findings, so existing flat reports parse exactly as before.
|
|
285
|
+
*
|
|
286
|
+
* @param {Array<{ level: number, title: string, bodyLines: string[] }>} blocks
|
|
287
|
+
* @returns {Array<{ level: number, title: string, bodyLines: string[] }>}
|
|
288
|
+
*/
|
|
289
|
+
function foldGroupingHeaders(blocks) {
|
|
290
|
+
const out = [];
|
|
291
|
+
let parent = null;
|
|
292
|
+
for (const block of blocks) {
|
|
293
|
+
if (block.level <= 3) {
|
|
294
|
+
parent = block;
|
|
295
|
+
out.push(block);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (!parent) {
|
|
299
|
+
out.push(block);
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (carriesSeverity(parent)) {
|
|
303
|
+
parent.bodyLines.push(`#### ${block.title}`, ...block.bodyLines);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
parent.isGroupingHeader = true;
|
|
307
|
+
out.push(block);
|
|
308
|
+
}
|
|
309
|
+
return out.filter((block) => !block.isGroupingHeader);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Read the machine-readable severity tally the report envelope mandates in its
|
|
314
|
+
* `## Executive Summary`:
|
|
315
|
+
*
|
|
316
|
+
* ```text
|
|
317
|
+
* Severity tally: Critical 0 / High 2 / Medium 1 / Low 0
|
|
318
|
+
* ```
|
|
319
|
+
*
|
|
320
|
+
* The line is what lets a consumer cross-check what the lens says it found
|
|
321
|
+
* against what the parser actually extracted — a parse that silently drops
|
|
322
|
+
* findings is otherwise indistinguishable from a clean report. `Info` is never
|
|
323
|
+
* counted (the severity scale already excludes it from scheduled work).
|
|
324
|
+
*
|
|
325
|
+
* @param {string} markdown — full report text.
|
|
326
|
+
* @returns {{ critical: number, high: number, medium: number, low: number }|null}
|
|
327
|
+
* `null` when the report declares no tally at all.
|
|
328
|
+
*/
|
|
329
|
+
export function parseSeverityTally(markdown) {
|
|
330
|
+
if (typeof markdown !== 'string') return null;
|
|
331
|
+
const match = TALLY_LINE.exec(markdown);
|
|
332
|
+
if (!match) return null;
|
|
333
|
+
return {
|
|
334
|
+
critical: Number(match[1]),
|
|
335
|
+
high: Number(match[2]),
|
|
336
|
+
medium: Number(match[3]),
|
|
337
|
+
low: Number(match[4]),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
252
341
|
function parseBlockFields(bodyLines) {
|
|
253
342
|
const fields = {};
|
|
254
343
|
let activeKey = null;
|
|
@@ -302,7 +391,7 @@ export function parseAuditReport({ markdown, sourceReport, repoRoot }) {
|
|
|
302
391
|
}
|
|
303
392
|
|
|
304
393
|
const fallbackDimension = inferDimensionFromReportName(sourceReport);
|
|
305
|
-
const blocks = splitFindingBlocks(markdown);
|
|
394
|
+
const blocks = foldGroupingHeaders(splitFindingBlocks(markdown));
|
|
306
395
|
|
|
307
396
|
return blocks.map((block) => {
|
|
308
397
|
const fields = parseBlockFields(block.bodyLines);
|
|
@@ -357,6 +446,8 @@ export function parseAuditReports(reports, { repoRoot } = {}) {
|
|
|
357
446
|
}
|
|
358
447
|
|
|
359
448
|
export const __testing = {
|
|
449
|
+
carriesSeverity,
|
|
450
|
+
foldGroupingHeaders,
|
|
360
451
|
normaliseSeverity,
|
|
361
452
|
extractFilePaths,
|
|
362
453
|
normaliseTitle,
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { SEVERITIES } from '../findings/severity.js';
|
|
21
|
+
import { formatEpicGrouping } from './epic-grouping-directive.js';
|
|
21
22
|
import {
|
|
22
23
|
renderFingerprintFooter,
|
|
23
24
|
renderSemanticKeyFooter,
|
|
@@ -137,6 +138,10 @@ function formatKeyAssumptions(sourceReports) {
|
|
|
137
138
|
* @param {string[]} params.sourceReports — list of source report paths.
|
|
138
139
|
* @returns {string}
|
|
139
140
|
*/
|
|
141
|
+
/**
|
|
142
|
+
* @param {{ groups: object[], findings: object[], sourceReports: object[] }} opts
|
|
143
|
+
* @returns {string} The `/mandrel-plan` seed one-pager.
|
|
144
|
+
*/
|
|
140
145
|
export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
|
|
141
146
|
if (
|
|
142
147
|
!Array.isArray(groups) ||
|
|
@@ -152,6 +157,7 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
|
|
|
152
157
|
const scope = formatMVPScope(groups);
|
|
153
158
|
const files = formatKeyFiles(groups);
|
|
154
159
|
const assumptions = formatKeyAssumptions(sourceReports);
|
|
160
|
+
const grouping = formatEpicGrouping(groups);
|
|
155
161
|
|
|
156
162
|
return [
|
|
157
163
|
'# Idea Seed: Audit Remediation',
|
|
@@ -176,6 +182,10 @@ export function buildPlanSeedMarkdown({ groups, findings, sourceReports }) {
|
|
|
176
182
|
'',
|
|
177
183
|
files,
|
|
178
184
|
'',
|
|
185
|
+
'## Grouping',
|
|
186
|
+
'',
|
|
187
|
+
grouping,
|
|
188
|
+
'',
|
|
179
189
|
'## Not Doing',
|
|
180
190
|
'',
|
|
181
191
|
'- Findings with severity below the operator-selected threshold.',
|
|
@@ -122,13 +122,98 @@ export function getChangedFiles({
|
|
|
122
122
|
return parseNameOnlyStdout(res.stdout);
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* A full-length hex object id, as `git rev-parse` prints it. Used to reject
|
|
127
|
+
* anything that is not a resolved commit — a stubbed git interface in a test
|
|
128
|
+
* answers every `gitSpawn` with the same canned stdout, and a file list must
|
|
129
|
+
* never be mistaken for a merge head.
|
|
130
|
+
*/
|
|
131
|
+
const OBJECT_ID_RE = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the commit an in-progress merge is merging **in**, or `null` when no
|
|
135
|
+
* merge is in progress.
|
|
136
|
+
*
|
|
137
|
+
* Story #5131. `git diff --cached` with no commit argument diffs the index
|
|
138
|
+
* against `HEAD`, and during a merge `HEAD` is still the pre-merge tip — so a
|
|
139
|
+
* base-sync merge commit (`git merge --no-edit origin/<base>`, which
|
|
140
|
+
* `single-story-close`'s base-sync phase tells the operator to run by hand)
|
|
141
|
+
* put every file the base branch had landed into the staged scope. The
|
|
142
|
+
* pre-commit MI/CRAP gate then blocked the resolution commit for deltas
|
|
143
|
+
* belonging to already-landed, already-gated work, with no remedy: the preview
|
|
144
|
+
* is a delta against the baseline, not a baseline comparison, so no baseline
|
|
145
|
+
* refresh could silence it.
|
|
146
|
+
*
|
|
147
|
+
* Two details are load-bearing:
|
|
148
|
+
*
|
|
149
|
+
* - **Ask git, never the filesystem.** `.git` is a *file*, not a directory,
|
|
150
|
+
* in the linked worktrees this repo delivers from, so an
|
|
151
|
+
* `existsSync('.git/MERGE_HEAD')` probe would be silently inert exactly
|
|
152
|
+
* where deliveries happen. `rev-parse --verify` resolves the ref through
|
|
153
|
+
* git's own worktree-aware lookup.
|
|
154
|
+
* - **`--verify` fails closed on an octopus merge.** It refuses a
|
|
155
|
+
* `MERGE_HEAD` naming more than one head, which lands here as `null` — the
|
|
156
|
+
* pre-#5131 behaviour. Narrowing the scope wrongly would hide a real
|
|
157
|
+
* regression; widening it only restores the status quo.
|
|
158
|
+
*
|
|
159
|
+
* Never throws: a merge is either detectable or it is not, and an
|
|
160
|
+
* undetectable one must degrade to the plain cached diff rather than fail the
|
|
161
|
+
* gate.
|
|
162
|
+
*
|
|
163
|
+
* @param {object} [params]
|
|
164
|
+
* @param {string} [params.cwd=process.cwd()]
|
|
165
|
+
* @param {ReturnType<typeof createGitInterface>} [params.git]
|
|
166
|
+
* @returns {string | null} The merge head's object id, or `null`.
|
|
167
|
+
*/
|
|
168
|
+
export function resolveMergeHead({ cwd = process.cwd(), git } = {}) {
|
|
169
|
+
const gitIface = git ?? createGitInterface({});
|
|
170
|
+
let res;
|
|
171
|
+
try {
|
|
172
|
+
res = gitIface.gitSpawn(cwd, 'rev-parse', '-q', '--verify', 'MERGE_HEAD');
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (res?.status !== 0) return null;
|
|
177
|
+
const sha = (res.stdout ?? '').trim();
|
|
178
|
+
return OBJECT_ID_RE.test(sha) ? sha : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Read the index file list against an explicit base, shared by
|
|
183
|
+
* `getStagedFiles` and `resolvePreviewScope` so the merge head is resolved
|
|
184
|
+
* once per scope resolution rather than once per caller.
|
|
185
|
+
*
|
|
186
|
+
* @param {object} params
|
|
187
|
+
* @param {string} params.cwd
|
|
188
|
+
* @param {ReturnType<typeof createGitInterface>} params.git
|
|
189
|
+
* @param {string | null} params.mergeHead
|
|
190
|
+
* @returns {string[]}
|
|
191
|
+
*/
|
|
192
|
+
function stagedFilesAgainst({ cwd, git, mergeHead }) {
|
|
193
|
+
const args = ['diff', '--name-only', '--cached'];
|
|
194
|
+
if (mergeHead) args.push(mergeHead);
|
|
195
|
+
const res = git.gitSpawn(cwd, ...args);
|
|
196
|
+
if (res.status !== 0) {
|
|
197
|
+
const detail = res.stderr || res.stdout || `exit ${res.status}`;
|
|
198
|
+
throw new Error(`[staged] unable to read cached diff: ${detail}`);
|
|
199
|
+
}
|
|
200
|
+
return parseNameOnlyStdout(res.stdout);
|
|
201
|
+
}
|
|
202
|
+
|
|
125
203
|
/**
|
|
126
204
|
* Resolve paths in the index (staged for commit). Used by `quality-preview
|
|
127
205
|
* --staged` so pre-commit gates score only the commit payload, not unstaged
|
|
128
206
|
* working-tree edits.
|
|
129
207
|
*
|
|
130
208
|
* Semantics:
|
|
131
|
-
* - Runs `git diff --name-only --cached
|
|
209
|
+
* - Runs `git diff --name-only --cached`, which diffs the index against
|
|
210
|
+
* `HEAD`.
|
|
211
|
+
* - **During a merge**, diffs the index against `MERGE_HEAD` instead
|
|
212
|
+
* (Story #5131), so the scope is the merging branch's own contribution
|
|
213
|
+
* plus its conflict resolutions — not the base branch's incoming work.
|
|
214
|
+
* `git merge-base HEAD MERGE_HEAD` would *not* do: diffing the index
|
|
215
|
+
* against the fork point re-admits everything the base branch landed since
|
|
216
|
+
* it, which is the whole defect.
|
|
132
217
|
* - Returns forward-slash-normalized repo-relative paths.
|
|
133
218
|
* - Non-zero git exit throws — staged mode must not silently widen scope.
|
|
134
219
|
*
|
|
@@ -139,12 +224,11 @@ export function getChangedFiles({
|
|
|
139
224
|
*/
|
|
140
225
|
export function getStagedFiles({ cwd = process.cwd(), git } = {}) {
|
|
141
226
|
const gitIface = git ?? createGitInterface({});
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
return parseNameOnlyStdout(res.stdout);
|
|
227
|
+
return stagedFilesAgainst({
|
|
228
|
+
cwd,
|
|
229
|
+
git: gitIface,
|
|
230
|
+
mergeHead: resolveMergeHead({ cwd, git: gitIface }),
|
|
231
|
+
});
|
|
148
232
|
}
|
|
149
233
|
|
|
150
234
|
/**
|
|
@@ -154,6 +238,11 @@ export function getStagedFiles({ cwd = process.cwd(), git } = {}) {
|
|
|
154
238
|
* is ignored. Otherwise a `changedSinceRef` limits to that three-dot diff;
|
|
155
239
|
* when both are absent the caller runs in full-repo mode (`scopeSet: null`).
|
|
156
240
|
*
|
|
241
|
+
* In `staged` scope, `diffRef` carries the in-progress merge head when there
|
|
242
|
+
* is one (Story #5131) and `null` otherwise, so a caller can tell the operator
|
|
243
|
+
* *why* the scope narrowed. `scope` stays `'staged'` either way — the merge is
|
|
244
|
+
* a property of the base the index is read against, not a different mode.
|
|
245
|
+
*
|
|
157
246
|
* @param {object} [params]
|
|
158
247
|
* @param {boolean} [params.staged=false]
|
|
159
248
|
* @param {string | null} [params.changedSinceRef=null]
|
|
@@ -172,8 +261,10 @@ export function resolvePreviewScope({
|
|
|
172
261
|
git,
|
|
173
262
|
} = {}) {
|
|
174
263
|
if (staged) {
|
|
175
|
-
const
|
|
176
|
-
|
|
264
|
+
const gitIface = git ?? createGitInterface({});
|
|
265
|
+
const mergeHead = resolveMergeHead({ cwd, git: gitIface });
|
|
266
|
+
const files = stagedFilesAgainst({ cwd, git: gitIface, mergeHead });
|
|
267
|
+
return { scopeSet: new Set(files), scope: 'staged', diffRef: mergeHead };
|
|
177
268
|
}
|
|
178
269
|
if (changedSinceRef) {
|
|
179
270
|
try {
|
|
@@ -622,6 +622,17 @@ const PLANNING_SCHEMA = {
|
|
|
622
622
|
// qa.* — Agent-driven QA harness contract (Epic #3214)
|
|
623
623
|
// ---------------------------------------------------------------------------
|
|
624
624
|
|
|
625
|
+
// The per-environment sign-in seam. `{ urlTemplate }` is a dev
|
|
626
|
+
// impersonation route; `{ skill }` names a skill by its tier-relative id
|
|
627
|
+
// (e.g. `stack/qa/acme-sso`), resolved against the payload skills root and
|
|
628
|
+
// then the consumer-writable `.agents/local/skills/` zone (Story #5135).
|
|
629
|
+
//
|
|
630
|
+
// No default anywhere in this file may name a skill id: the framework ships
|
|
631
|
+
// no sign-in skill, so any id baked into an inventory value would be a
|
|
632
|
+
// dangling pointer a consumer copies verbatim — the exact defect #5134
|
|
633
|
+
// reported. The `{ skill }` arm is taught in prose (`.agents/README.md`
|
|
634
|
+
// § "Expose a `signInSeam`"), and `resolveQaEnvironment` fails loudly on an
|
|
635
|
+
// id that resolves under neither root.
|
|
625
636
|
const QA_SIGN_IN_SEAM_SCHEMA = {
|
|
626
637
|
oneOf: [
|
|
627
638
|
{
|
|
@@ -654,9 +665,13 @@ const QA_PERSONAS_SCHEMA = {
|
|
|
654
665
|
description:
|
|
655
666
|
'Personas the QA-harness sign-in seam accepts. Two accepted shapes: (1) a plain array of persona names — the honest shape for a `urlTemplate` dev-impersonation seam, where the persona name is the sole input the workflow consumes; (2) the object-map form keyed by persona name, where each entry carries per-persona auth material (`credentialRef` or `signInSkill`) consulted only under a skill-based or credential-based seam.',
|
|
656
667
|
// Inventory value: an illustrative map showing both per-persona shapes.
|
|
668
|
+
// Inventory value: illustrative credential references, not resolvable
|
|
669
|
+
// ones. It deliberately does NOT illustrate the `signInSkill` arm — a
|
|
670
|
+
// skill id in a shipped default is a dangling pointer (see
|
|
671
|
+
// QA_SIGN_IN_SEAM_SCHEMA above); that arm is taught in prose instead.
|
|
657
672
|
default: {
|
|
658
673
|
admin: { credentialRef: 'QA_ADMIN_CREDENTIAL' },
|
|
659
|
-
member: {
|
|
674
|
+
member: { credentialRef: 'QA_MEMBER_CREDENTIAL' },
|
|
660
675
|
},
|
|
661
676
|
oneOf: [
|
|
662
677
|
{
|
|
@@ -703,11 +718,11 @@ const QA_PERSONAS_SCHEMA = {
|
|
|
703
718
|
const QA_ENVIRONMENTS_SCHEMA = {
|
|
704
719
|
type: 'object',
|
|
705
720
|
description:
|
|
706
|
-
'Deployment targets the QA harness can run against (Epic #4326). A map keyed by environment name (e.g. `local`, `staging`), each carrying its own `baseUrl`,
|
|
721
|
+
'Deployment targets the QA harness can run against (Epic #4326). A map keyed by environment name (e.g. `local`, `staging`), each carrying its own `baseUrl`, an optional per-environment sign-in seam, and an optional `allowWrites` gate. `signInSeam` is the union `{ urlTemplate }` (a dev impersonation route) or `{ skill }` (a skill id such as `stack/qa/acme-sso`, resolved against `.agents/skills/` then the consumer-writable `.agents/local/skills/` zone, and rejected loudly by resolveQaEnvironment when it resolves under neither); omit it entirely for a target with no sign-in seam. resolveQaEnvironment selects one environment per invocation by name or by raw-URL origin match against `baseUrl`; `allowWrites` defaults to true only for the `local` environment. Replaces the retired top-level single `signInSeam`.',
|
|
707
722
|
// Inventory value: an illustrative two-environment map, not a resolvable
|
|
708
|
-
// default.
|
|
709
|
-
//
|
|
710
|
-
//
|
|
723
|
+
// default. `staging` deliberately carries NO `signInSeam` — that is the
|
|
724
|
+
// honest shape for a deployed target with no dev sign-in seam, and it
|
|
725
|
+
// shows the field is optional (Story #5135).
|
|
711
726
|
default: {
|
|
712
727
|
local: {
|
|
713
728
|
baseUrl: 'http://localhost:3000',
|
|
@@ -715,7 +730,6 @@ const QA_ENVIRONMENTS_SCHEMA = {
|
|
|
715
730
|
},
|
|
716
731
|
staging: {
|
|
717
732
|
baseUrl: 'https://staging.example.test',
|
|
718
|
-
signInSeam: { skill: 'stack/qa/sign-in' },
|
|
719
733
|
allowWrites: false,
|
|
720
734
|
},
|
|
721
735
|
},
|
|
@@ -727,7 +741,11 @@ const QA_ENVIRONMENTS_SCHEMA = {
|
|
|
727
741
|
signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
|
|
728
742
|
allowWrites: { type: 'boolean' },
|
|
729
743
|
},
|
|
730
|
-
|
|
744
|
+
// `signInSeam` is OPTIONAL (Story #5135). An environment that resolves no
|
|
745
|
+
// seam is a state the QA workflows already branch on — they drive the
|
|
746
|
+
// unauthenticated surface and record the gap — so requiring it made an
|
|
747
|
+
// honestly seamless remote target undeclarable.
|
|
748
|
+
required: ['baseUrl'],
|
|
731
749
|
additionalProperties: false,
|
|
732
750
|
},
|
|
733
751
|
};
|