mandrel 2.37.0 → 2.39.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.
Files changed (162) hide show
  1. package/.agents/README.md +61 -24
  2. package/.agents/agents/plan-critic.md +1 -1
  3. package/.agents/docs/SDLC.md +40 -40
  4. package/.agents/docs/agentrc-reference.json +1 -4
  5. package/.agents/docs/configuration.md +18 -18
  6. package/.agents/docs/execution-reference.md +3 -3
  7. package/.agents/docs/quality-gates.md +2 -2
  8. package/.agents/docs/workflows.md +6 -6
  9. package/.agents/instructions.md +5 -5
  10. package/.agents/rules/changelog-style.md +3 -3
  11. package/.agents/rules/ci-remediation.md +1 -1
  12. package/.agents/rules/git-conventions-reference.md +4 -4
  13. package/.agents/schemas/agentrc.schema.json +15 -16
  14. package/.agents/schemas/story-deliver-terminal.schema.json +2 -2
  15. package/.agents/schemas/validation-evidence.schema.json +1 -1
  16. package/.agents/scripts/acceptance-eval.js +1 -1
  17. package/.agents/scripts/apply-quality-bootstrap.js +1 -1
  18. package/.agents/scripts/audit-to-stories.js +2 -2
  19. package/.agents/scripts/check-doc-links.js +32 -4
  20. package/.agents/scripts/check-test-temp-hygiene.js +1 -1
  21. package/.agents/scripts/check-workflow-cli-lint.js +1 -1
  22. package/.agents/scripts/deliver-light.js +10 -10
  23. package/.agents/scripts/deliver-recover.js +1 -1
  24. package/.agents/scripts/drain-pending-cleanup.js +1 -1
  25. package/.agents/scripts/evidence-gate.js +1 -1
  26. package/.agents/scripts/generate-skills-index.js +158 -75
  27. package/.agents/scripts/generate-workflows-doc.js +1 -1
  28. package/.agents/scripts/lib/audit-suite/selector.js +1 -1
  29. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +1 -1
  30. package/.agents/scripts/lib/audit-to-stories/build-story-body.js +1 -1
  31. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +5 -5
  32. package/.agents/scripts/lib/audit-to-stories/wire-dependencies.js +1 -1
  33. package/.agents/scripts/lib/bdd-runner-detect.js +1 -1
  34. package/.agents/scripts/lib/bdd-scenario-budget.js +1 -1
  35. package/.agents/scripts/lib/bdd-scenario-scanner.js +3 -3
  36. package/.agents/scripts/lib/bdd-step-index.js +1 -1
  37. package/.agents/scripts/lib/bootstrap/branch-protection.js +1 -1
  38. package/.agents/scripts/lib/bootstrap/commit-push.js +2 -2
  39. package/.agents/scripts/lib/bootstrap/issue-forms-template.js +1 -1
  40. package/.agents/scripts/lib/changed-files.js +100 -9
  41. package/.agents/scripts/lib/checks/core-bare-clean.js +1 -1
  42. package/.agents/scripts/lib/checks/story-init-not-backgrounded.js +1 -1
  43. package/.agents/scripts/lib/cli-args.js +1 -1
  44. package/.agents/scripts/lib/command-header.js +1 -1
  45. package/.agents/scripts/lib/config/delivery-routing.js +1 -1
  46. package/.agents/scripts/lib/config/explain.js +1 -1
  47. package/.agents/scripts/lib/config/runners.js +2 -2
  48. package/.agents/scripts/lib/config/runtime.js +1 -1
  49. package/.agents/scripts/lib/config/temp-paths.js +2 -2
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +3 -3
  51. package/.agents/scripts/lib/config-settings-schema-quality.js +6 -6
  52. package/.agents/scripts/lib/config-settings-schema.js +28 -10
  53. package/.agents/scripts/lib/duplicate-search.js +1 -1
  54. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +4 -4
  55. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +1 -1
  56. package/.agents/scripts/lib/findings/classify-finding.js +1 -1
  57. package/.agents/scripts/lib/findings/promote-finding.js +12 -12
  58. package/.agents/scripts/lib/findings/route-finding.js +2 -2
  59. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  60. package/.agents/scripts/lib/git-branch-lifecycle.js +2 -2
  61. package/.agents/scripts/lib/label-constants.js +2 -2
  62. package/.agents/scripts/lib/label-taxonomy.js +1 -1
  63. package/.agents/scripts/lib/onboard/init-tail.js +4 -4
  64. package/.agents/scripts/lib/onboard/scaffold-docs.js +2 -2
  65. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  66. package/.agents/scripts/lib/orchestration/code-review.js +4 -4
  67. package/.agents/scripts/lib/orchestration/complexity-gate.js +5 -5
  68. package/.agents/scripts/lib/orchestration/deliver-recover.js +3 -3
  69. package/.agents/scripts/lib/orchestration/docs-digest.js +3 -3
  70. package/.agents/scripts/lib/orchestration/lease-guard-shared.js +2 -2
  71. package/.agents/scripts/lib/orchestration/light-backstop.js +1 -1
  72. package/.agents/scripts/lib/orchestration/light-escalation.js +6 -6
  73. package/.agents/scripts/lib/orchestration/light-suitability.js +19 -19
  74. package/.agents/scripts/lib/orchestration/plan-context.js +4 -4
  75. package/.agents/scripts/lib/orchestration/plan-critic-conditions.js +2 -2
  76. package/.agents/scripts/lib/orchestration/plan-critics-evaluate.js +2 -2
  77. package/.agents/scripts/lib/orchestration/plan-metrics.js +1 -1
  78. package/.agents/scripts/lib/orchestration/plan-persist/persist-helpers.js +1 -1
  79. package/.agents/scripts/lib/orchestration/plan-persist/plan-context-source.js +3 -3
  80. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +6 -6
  81. package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +16 -16
  82. package/.agents/scripts/lib/orchestration/plan-persist/summary.js +3 -3
  83. package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +6 -6
  84. package/.agents/scripts/lib/orchestration/planning/authoring-context.js +7 -7
  85. package/.agents/scripts/lib/orchestration/planning/decomposer-context.js +1 -1
  86. package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +3 -3
  87. package/.agents/scripts/lib/orchestration/remote-verifier.js +2 -2
  88. package/.agents/scripts/lib/orchestration/resolve-stories.js +4 -4
  89. package/.agents/scripts/lib/orchestration/run-epilogue.js +2 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +2 -2
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/conventional-subject.js +1 -1
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +5 -1
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-block.js +1 -1
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +3 -3
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -2
  96. package/.agents/scripts/lib/orchestration/single-story-lease-guard.js +1 -1
  97. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +14 -12
  98. package/.agents/scripts/lib/orchestration/ticket-validator.js +1 -1
  99. package/.agents/scripts/lib/orchestration/ticketing/reads.js +3 -3
  100. package/.agents/scripts/lib/plan-phase-cleanup.js +1 -1
  101. package/.agents/scripts/lib/qa/resolve-qa-contract.js +58 -6
  102. package/.agents/scripts/lib/signals/schema.js +1 -1
  103. package/.agents/scripts/lib/skills/skills-index.js +168 -0
  104. package/.agents/scripts/lib/skills/walk-skill-files.js +133 -9
  105. package/.agents/scripts/lib/story-adjacency.js +2 -2
  106. package/.agents/scripts/lib/templates/decomposer-prompts.js +1 -1
  107. package/.agents/scripts/lib/ticket-body-sections.js +1 -1
  108. package/.agents/scripts/lib/util/concurrent-map.js +1 -1
  109. package/.agents/scripts/lib/util/parse-id-list.js +1 -1
  110. package/.agents/scripts/lib/wave-runner/live-probe.js +4 -4
  111. package/.agents/scripts/lib/wave-runner/ready-set.js +1 -1
  112. package/.agents/scripts/lib/workflow-closure.js +1 -1
  113. package/.agents/scripts/lib/worktree/lifecycle/pending-cleanup.js +2 -2
  114. package/.agents/scripts/lint-issue-body.js +3 -3
  115. package/.agents/scripts/plan-context.js +4 -4
  116. package/.agents/scripts/plan-critics.js +2 -2
  117. package/.agents/scripts/plan-persist.js +2 -2
  118. package/.agents/scripts/plan-run-epilogue.js +2 -2
  119. package/.agents/scripts/providers/github/board-add.js +1 -1
  120. package/.agents/scripts/providers/github/errors.js +1 -1
  121. package/.agents/scripts/providers/github/tickets.js +6 -6
  122. package/.agents/scripts/quality-preview.js +50 -9
  123. package/.agents/scripts/resolve-stories.js +3 -3
  124. package/.agents/scripts/resync-status-column.js +1 -1
  125. package/.agents/scripts/run-lint.js +1 -1
  126. package/.agents/scripts/single-story-close.js +2 -2
  127. package/.agents/scripts/single-story-init.js +5 -5
  128. package/.agents/scripts/stories-wave-tick.js +8 -8
  129. package/.agents/scripts/validate-skills.js +53 -66
  130. package/.agents/skills/core/idea-refinement/SKILL.md +5 -5
  131. package/.agents/skills/core/scope-triage/SKILL.md +3 -3
  132. package/.agents/skills/skills.index.json +1 -1
  133. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -1
  134. package/.agents/workflows/audit-data-model.md +1 -1
  135. package/.agents/workflows/audit-documentation.md +2 -2
  136. package/.agents/workflows/audit-to-stories.md +11 -11
  137. package/.agents/workflows/git-deliver.md +4 -4
  138. package/.agents/workflows/helpers/_merge-conflict-template.md +1 -1
  139. package/.agents/workflows/helpers/audit-lens-core.md +1 -1
  140. package/.agents/workflows/helpers/code-review.md +4 -4
  141. package/.agents/workflows/helpers/deliver-light.md +26 -26
  142. package/.agents/workflows/helpers/deliver-reference.md +15 -7
  143. package/.agents/workflows/helpers/deliver-story-reference.md +9 -4
  144. package/.agents/workflows/helpers/deliver-story.md +6 -7
  145. package/.agents/workflows/helpers/diagnose.md +2 -2
  146. package/.agents/workflows/helpers/parallel-tooling.md +1 -1
  147. package/.agents/workflows/helpers/plan-reference.md +9 -9
  148. package/.agents/workflows/helpers/qa-core.md +8 -8
  149. package/.agents/workflows/helpers/worktree-lifecycle.md +6 -6
  150. package/.agents/workflows/{deliver.md → mandrel-deliver.md} +15 -19
  151. package/.agents/workflows/{plan.md → mandrel-plan.md} +9 -9
  152. package/.agents/workflows/memory-consolidate.md +4 -4
  153. package/.agents/workflows/prototype.md +3 -3
  154. package/.agents/workflows/qa-assist.md +12 -12
  155. package/.agents/workflows/qa-explore.md +6 -6
  156. package/.agents/workflows/qa-run.md +14 -6
  157. package/README.md +7 -7
  158. package/docs/CHANGELOG.md +23 -0
  159. package/lib/cli/doctor.js +1 -1
  160. package/lib/cli/init.js +1 -1
  161. package/lib/migrations/steps/2.2.0-retire-epic-ac-tags.js +1 -1
  162. package/package.json +1 -1
@@ -26,11 +26,25 @@
26
26
  * harness invocation — by exact name or by raw-URL origin match against each
27
27
  * environment's `baseUrl` — and throws loudly (naming the known environments)
28
28
  * on an unknown name or unmatched URL.
29
+ *
30
+ * It also **resolves the selected environment's `signInSeam`** (Story #5135).
31
+ * A `{ skill }` seam naming an id that resolves to no readable `SKILL.md`
32
+ * under either skills root used to fail silently: the contract validated, the
33
+ * seam was returned unread, and the dangling pointer only surfaced much later
34
+ * when a sweep reached its sign-in step — after the harness had already
35
+ * driven a browser. Resolution now happens here, at config-resolution time,
36
+ * so the failure lands where the operator can fix `.agentrc.json`. A seam
37
+ * that is absent entirely is a legitimate, declarable state (the workflows
38
+ * drive the unauthenticated surface and record the gap), not an error.
29
39
  */
30
40
 
31
41
  import Ajv from 'ajv';
32
-
33
42
  import { QA_SCHEMA } from '../config-settings-schema.js';
43
+ import { PROJECT_ROOT } from '../project-root.js';
44
+ import {
45
+ resolveSkillFile,
46
+ SKILL_SEARCH_ROOTS,
47
+ } from '../skills/walk-skill-files.js';
34
48
 
35
49
  /**
36
50
  * The harness-required fields. The AJV `QA_SCHEMA` keeps these optional so
@@ -242,6 +256,40 @@ function toOrigin(value) {
242
256
  }
243
257
  }
244
258
 
259
+ /**
260
+ * Normalize and verify one environment's `signInSeam`.
261
+ *
262
+ * An absent seam normalizes to `null` — a declarable state, not an error.
263
+ * A `{ skill }` seam is resolved against both skills roots and throws when
264
+ * it resolves under neither, so a dangling pointer is caught here rather
265
+ * than mid-sweep. The resolved `SKILL.md` path is attached as
266
+ * `skillPath` so the harness reads the file the check actually found.
267
+ *
268
+ * @param {object | undefined} seam
269
+ * @param {string} envName Environment name, for the error message.
270
+ * @param {{ repoRoot?: string }} options
271
+ * @returns {object | null}
272
+ */
273
+ function resolveSignInSeam(seam, envName, options) {
274
+ if (seam == null) return null;
275
+ if (typeof seam.skill !== 'string') return seam;
276
+
277
+ const repoRoot = options.repoRoot ?? PROJECT_ROOT;
278
+ const found = resolveSkillFile(repoRoot, seam.skill);
279
+ if (found === null) {
280
+ throw new Error(
281
+ `qa: environment \`${envName}\` declares signInSeam.skill ` +
282
+ `\`${seam.skill}\`, which resolves to no readable SKILL.md. ` +
283
+ `Searched ${SKILL_SEARCH_ROOTS.map((r) => `\`${r}/<skill>/SKILL.md\``).join(' and ')}. ` +
284
+ 'Author the skill under the consumer-writable `.agents/local/skills/` ' +
285
+ 'zone (it is never pruned by `mandrel sync` and never flagged as ' +
286
+ 'payload drift), correct the id, or omit `signInSeam` entirely if ' +
287
+ 'this target genuinely has no sign-in seam.',
288
+ );
289
+ }
290
+ return { ...seam, skillPath: found.path };
291
+ }
292
+
245
293
  /**
246
294
  * Resolve a single QA environment for one harness invocation.
247
295
  *
@@ -262,13 +310,17 @@ function toOrigin(value) {
262
310
  * Fails **loudly**: an unknown name or an unmatched URL throws an error that
263
311
  * names the known environments so the operator can correct the invocation.
264
312
  *
265
- * @param {{ environments: Record<string, { baseUrl: string, signInSeam: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
313
+ * @param {{ environments: Record<string, { baseUrl: string, signInSeam?: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
266
314
  * A contract returned by `resolveQaContract`.
267
315
  * @param {string} [target] Environment name or raw URL. Omit for the default.
268
- * @returns {{ name: string, baseUrl: string, signInSeam: object, allowWrites: boolean }}
269
- * @throws {Error} on an unknown name or unmatched URL.
316
+ * @param {{ repoRoot?: string }} [options] `repoRoot` roots skill-seam
317
+ * resolution; defaults to the project root. Injected by tests.
318
+ * @returns {{ name: string, baseUrl: string, signInSeam: object | null, allowWrites: boolean }}
319
+ * `signInSeam` is `null` when the environment declares none.
320
+ * @throws {Error} on an unknown name, an unmatched URL, or a `{ skill }` seam
321
+ * that resolves under no skills root.
270
322
  */
271
- export function resolveQaEnvironment(contract, target) {
323
+ export function resolveQaEnvironment(contract, target, options = {}) {
272
324
  const environments = contract?.environments;
273
325
  if (
274
326
  environments == null ||
@@ -320,7 +372,7 @@ export function resolveQaEnvironment(contract, target) {
320
372
  return {
321
373
  name: resolvedName,
322
374
  baseUrl: env.baseUrl,
323
- signInSeam: env.signInSeam,
375
+ signInSeam: resolveSignInSeam(env.signInSeam, resolvedName, options),
324
376
  allowWrites,
325
377
  };
326
378
  }
@@ -91,7 +91,7 @@ export const EVENT_KINDS = Object.freeze({
91
91
  // Story #3819 — per-criterion acceptance self-eval signal emitted by
92
92
  // acceptance-eval.js. One record per Story per eval-loop terminus,
93
93
  // carrying which acceptance items needed rework and the round count, so
94
- // the retro and /plan Phase 0 feedback fetch can see acceptance
94
+ // the retro and /mandrel-plan Phase 0 feedback fetch can see acceptance
95
95
  // churn alongside friction/hotspot data.
96
96
  ACCEPTANCE_EVAL: 'acceptance-eval',
97
97
  // Forensic breadcrumb appended to the per-Epic stream by the notify
@@ -0,0 +1,168 @@
1
+ // .agents/scripts/lib/skills/skills-index.js
2
+ //
3
+ // Shared I/O for the two skills manifests (Story #5135).
4
+ //
5
+ // Each skills root carries its own `skills.index.json`: the package payload's
6
+ // at `.agents/skills/`, and the consumer-writable zone's at
7
+ // `.agents/local/skills/`. The shipped one is a committed payload file that
8
+ // `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against the
9
+ // installed package, so the two manifests must never be merged — but they are
10
+ // read, compared and reported identically, and both CLIs need that logic.
11
+ // Before this module `generate-skills-index.js` and `validate-skills.js`
12
+ // each carried their own near-identical reader.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ /** Manifest filename, shared by both roots. */
18
+ export const INDEX_FILENAME = 'skills.index.json';
19
+
20
+ /**
21
+ * Absolute path of the manifest for one skills root.
22
+ *
23
+ * @param {string} repoRoot
24
+ * @param {readonly string[]} rootSegments From `walk-skill-files.js`.
25
+ * @returns {string}
26
+ */
27
+ export function indexPathFor(repoRoot, rootSegments) {
28
+ return path.join(repoRoot, ...rootSegments, INDEX_FILENAME);
29
+ }
30
+
31
+ /**
32
+ * Read a manifest from disk. Distinguishes "missing" from "unparseable" via
33
+ * the `reason` channel so callers can report which drift they hit rather than
34
+ * collapsing both into "not fresh".
35
+ *
36
+ * @param {string} indexPath
37
+ * @returns {{ manifest: object | null, reason: string | null }}
38
+ */
39
+ export function readManifest(indexPath) {
40
+ if (!fs.existsSync(indexPath)) {
41
+ return { manifest: null, reason: 'missing' };
42
+ }
43
+ let src;
44
+ try {
45
+ src = fs.readFileSync(indexPath, 'utf8');
46
+ } catch (err) {
47
+ return { manifest: null, reason: `read-error: ${err.message}` };
48
+ }
49
+ try {
50
+ return { manifest: JSON.parse(src), reason: null };
51
+ } catch (err) {
52
+ return { manifest: null, reason: `parse-error: ${err.message}` };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Read a manifest and project its entry paths into a Set, the shape the
58
+ * validator's membership check consumes.
59
+ *
60
+ * @param {string} indexPath
61
+ * @returns {{ exists: boolean, paths: Set<string> | null, manifest: object | null, indexPath: string, parseError?: string }}
62
+ */
63
+ export function readIndexPaths(indexPath) {
64
+ const { manifest, reason } = readManifest(indexPath);
65
+ if (reason === 'missing') {
66
+ return { exists: false, paths: null, manifest: null, indexPath };
67
+ }
68
+ if (manifest === null) {
69
+ return {
70
+ exists: true,
71
+ paths: null,
72
+ manifest: null,
73
+ indexPath,
74
+ parseError: reason,
75
+ };
76
+ }
77
+ const paths = new Set(
78
+ Array.isArray(manifest.skills)
79
+ ? manifest.skills.map((s) => s.path).filter((p) => typeof p === 'string')
80
+ : [],
81
+ );
82
+ return { exists: true, paths, manifest, indexPath };
83
+ }
84
+
85
+ /**
86
+ * Compare two manifests ignoring `generatedAt` — the one volatile field, which
87
+ * changes on every write and is not content. Returns null when they match, or
88
+ * a diff-style message naming the entry counts.
89
+ *
90
+ * @param {object | null} diskManifest
91
+ * @param {object} freshManifest
92
+ * @param {string} label Manifest name for the message.
93
+ * @returns {string | null}
94
+ */
95
+ export function diffManifests(diskManifest, freshManifest, label) {
96
+ if (diskManifest === null) {
97
+ return `${label}: on-disk manifest is missing or unreadable`;
98
+ }
99
+ const a = { ...diskManifest };
100
+ const b = { ...freshManifest };
101
+ a.generatedAt = undefined;
102
+ b.generatedAt = undefined;
103
+ if (JSON.stringify(a) === JSON.stringify(b)) return null;
104
+ const count = (m) => (Array.isArray(m.skills) ? m.skills.length : 'n/a');
105
+ return [
106
+ `${label} drift detected:`,
107
+ ` on-disk entries: ${count(diskManifest)}`,
108
+ ` generated entries: ${count(freshManifest)}`,
109
+ " run 'node .agents/scripts/generate-skills-index.js' to refresh",
110
+ ].join('\n');
111
+ }
112
+
113
+ /**
114
+ * Render a manifest's schema violations as field-named findings. The compiled
115
+ * AJV validator is passed in so this module stays free of the schema-loading
116
+ * side effects the validator CLI owns.
117
+ *
118
+ * @param {object} manifest
119
+ * @param {string} indexRelPath Repo-relative manifest path, for the message.
120
+ * @param {(m: object) => boolean} validateManifest Compiled AJV validator.
121
+ * @returns {string[]}
122
+ */
123
+ function validateManifestSchema(manifest, indexRelPath, validateManifest) {
124
+ const findings = [];
125
+ if (validateManifest(manifest)) return findings;
126
+ for (const err of validateManifest.errors ?? []) {
127
+ const where = err.instancePath || '(root)';
128
+ findings.push(
129
+ `${indexRelPath}: manifest-schema: schema violation at ${where}: ${err.message}`,
130
+ );
131
+ }
132
+ return findings;
133
+ }
134
+
135
+ /**
136
+ * Audit one root's manifest: present, parseable, and schema-valid. Shared by
137
+ * both skills roots so a consumer-authored index is held to the same bar as
138
+ * the shipped one.
139
+ *
140
+ * @param {{ exists: boolean, paths: Set<string> | null, manifest: object | null, parseError?: string }} indexInfo
141
+ * @param {string} indexRelPath
142
+ * @param {(m: object) => boolean} validateManifest
143
+ * @param {{ required: boolean }} options
144
+ * @returns {string[]}
145
+ */
146
+ export function auditIndex(
147
+ indexInfo,
148
+ indexRelPath,
149
+ validateManifest,
150
+ { required },
151
+ ) {
152
+ if (!indexInfo.exists) {
153
+ return required
154
+ ? [
155
+ `index missing: ${indexRelPath} not found — run 'node .agents/scripts/generate-skills-index.js'`,
156
+ ]
157
+ : [];
158
+ }
159
+ if (indexInfo.paths === null) {
160
+ return [`index unparseable: ${indexRelPath} — ${indexInfo.parseError}`];
161
+ }
162
+ if (indexInfo.manifest === null) return [];
163
+ return validateManifestSchema(
164
+ indexInfo.manifest,
165
+ indexRelPath,
166
+ validateManifest,
167
+ );
168
+ }
@@ -1,12 +1,56 @@
1
1
  // .agents/scripts/lib/skills/walk-skill-files.js
2
2
  //
3
- // Shared traversal for SKILL.md files under `.agents/skills/{core,stack}/`.
3
+ // Shared traversal for SKILL.md files across the two skills roots:
4
+ // the package payload (`.agents/skills/{core,stack}/`) and the
5
+ // consumer-writable local zone (`.agents/local/skills/{core,stack}/`).
4
6
  // Used by validate-skills.js and generate-skills-index.js so both CLIs
5
7
  // enumerate the same paths in the same deterministic order.
8
+ //
9
+ // The two roots stay **separately enumerable** on purpose (Story #5135).
10
+ // `.agents/skills/skills.index.json` is a committed payload file that
11
+ // `mandrel doctor` / `mandrel sync-agents` compare byte-for-byte against
12
+ // the installed package; folding a consumer's local skills into it would
13
+ // make every consumer's regenerated index read as payload drift and cause
14
+ // those commands to refuse. The local zone therefore carries its own
15
+ // index artifact, and the two roots are unified only at *lookup* time, by
16
+ // `resolveSkillFile` — never for the shipped manifest.
6
17
 
7
18
  import fs from 'node:fs';
8
19
  import path from 'node:path';
9
20
 
21
+ /** Tier directories a skills root is enumerated under. */
22
+ const TIERS = Object.freeze(['core', 'stack']);
23
+
24
+ /**
25
+ * Path segments (from the repo root) of the package-payload skills root.
26
+ * Materialized by `mandrel sync`; every file under it is payload.
27
+ */
28
+ export const PAYLOAD_SKILLS_SEGMENTS = Object.freeze(['.agents', 'skills']);
29
+
30
+ /**
31
+ * Path segments (from the repo root) of the consumer-writable skills root.
32
+ * It sits inside the `.agents/local/` zone (Story #3498), which sync never
33
+ * copies into and never prunes, and which the agents-drift check cannot
34
+ * flag because that check only walks files present in the package payload.
35
+ */
36
+ export const LOCAL_SKILLS_SEGMENTS = Object.freeze([
37
+ '.agents',
38
+ 'local',
39
+ 'skills',
40
+ ]);
41
+
42
+ /**
43
+ * A skill id is the tier-relative path naming a skill — e.g.
44
+ * `core/scope-triage` or `stack/qa/playwright`. It is the value that
45
+ * appears in `skills.index.json` minus the root prefix, and the value a
46
+ * `qa.environments.*.signInSeam.skill` seam carries.
47
+ *
48
+ * The pattern is deliberately strict: ids resolve to filesystem paths, so
49
+ * anything that could escape a root (`..`, absolute paths, backslashes) or
50
+ * smuggle a shell metacharacter is rejected rather than normalized.
51
+ */
52
+ const SKILL_ID_RE = /^[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9][a-z0-9._-]*)+$/;
53
+
10
54
  /**
11
55
  * Recursively enumerate `SKILL.md` paths under a directory.
12
56
  *
@@ -38,19 +82,99 @@ function walkSkillFiles(rootDir) {
38
82
  }
39
83
 
40
84
  /**
41
- * Build the list of SKILL.md files under `<repoRoot>/.agents/skills/{core,
42
- * stack}/`, sorted by POSIX repo-relative path for deterministic output.
85
+ * Sort absolute paths by their POSIX repo-relative form so output order is
86
+ * deterministic across platforms.
43
87
  *
88
+ * @param {string[]} files
44
89
  * @param {string} repoRoot
45
- * @returns {string[]} absolute paths
90
+ * @returns {string[]}
46
91
  */
47
- export function collectSkillFiles(repoRoot) {
48
- const skillsRoot = path.join(repoRoot, '.agents', 'skills');
49
- const coreFiles = walkSkillFiles(path.join(skillsRoot, 'core'));
50
- const stackFiles = walkSkillFiles(path.join(skillsRoot, 'stack'));
51
- return [...coreFiles, ...stackFiles].sort((a, b) => {
92
+ function sortByRepoRelative(files, repoRoot) {
93
+ return [...files].sort((a, b) => {
52
94
  const ra = path.relative(repoRoot, a).split(path.sep).join('/');
53
95
  const rb = path.relative(repoRoot, b).split(path.sep).join('/');
54
96
  return ra < rb ? -1 : ra > rb ? 1 : 0;
55
97
  });
56
98
  }
99
+
100
+ /**
101
+ * Enumerate the `SKILL.md` files under one skills root, sorted by POSIX
102
+ * repo-relative path.
103
+ *
104
+ * @param {string} repoRoot
105
+ * @param {readonly string[]} rootSegments One of the exported segment lists.
106
+ * @returns {string[]} absolute paths
107
+ */
108
+ function collectUnderRoot(repoRoot, rootSegments) {
109
+ const skillsRoot = path.join(repoRoot, ...rootSegments);
110
+ const files = TIERS.flatMap((tier) =>
111
+ walkSkillFiles(path.join(skillsRoot, tier)),
112
+ );
113
+ return sortByRepoRelative(files, repoRoot);
114
+ }
115
+
116
+ /**
117
+ * Build the list of payload SKILL.md files under
118
+ * `<repoRoot>/.agents/skills/{core,stack}/`.
119
+ *
120
+ * This is the set the **shipped** `skills.index.json` is generated from —
121
+ * it must never include local-zone skills (see the module header).
122
+ *
123
+ * @param {string} repoRoot
124
+ * @returns {string[]} absolute paths
125
+ */
126
+ export function collectSkillFiles(repoRoot) {
127
+ return collectUnderRoot(repoRoot, PAYLOAD_SKILLS_SEGMENTS);
128
+ }
129
+
130
+ /**
131
+ * Build the list of consumer-authored SKILL.md files under
132
+ * `<repoRoot>/.agents/local/skills/{core,stack}/`. Empty when the
133
+ * consumer has authored none — the common case, and the case in this
134
+ * repository itself.
135
+ *
136
+ * @param {string} repoRoot
137
+ * @returns {string[]} absolute paths
138
+ */
139
+ export function collectLocalSkillFiles(repoRoot) {
140
+ return collectUnderRoot(repoRoot, LOCAL_SKILLS_SEGMENTS);
141
+ }
142
+
143
+ /**
144
+ * Resolve a skill id to a readable `SKILL.md`, searching the payload root
145
+ * first and the local zone second (payload-wins, matching
146
+ * {@link collectAllSkillFiles}).
147
+ *
148
+ * Returns `null` rather than throwing so callers own the error message —
149
+ * a config resolver wants to name the offending config key, a workflow
150
+ * wants to name the seam.
151
+ *
152
+ * @param {string} repoRoot
153
+ * @param {string} skillId Tier-relative id, e.g. `stack/qa/acme-sso`.
154
+ * @returns {{ path: string, root: string } | null} absolute `SKILL.md`
155
+ * path and the POSIX repo-relative root it resolved under.
156
+ */
157
+ export function resolveSkillFile(repoRoot, skillId) {
158
+ if (typeof skillId !== 'string' || !SKILL_ID_RE.test(skillId)) return null;
159
+ for (const segments of [PAYLOAD_SKILLS_SEGMENTS, LOCAL_SKILLS_SEGMENTS]) {
160
+ const candidate = path.join(repoRoot, ...segments, skillId, 'SKILL.md');
161
+ try {
162
+ if (fs.statSync(candidate).isFile()) {
163
+ return { path: candidate, root: segments.join('/') };
164
+ }
165
+ } catch {
166
+ // Unreadable or absent — try the next root.
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /**
173
+ * The POSIX repo-relative skills roots, in search order. Exported so error
174
+ * messages can name exactly what was searched rather than restating the
175
+ * paths as literals.
176
+ */
177
+ export const SKILL_SEARCH_ROOTS = Object.freeze([
178
+ PAYLOAD_SKILLS_SEGMENTS.join('/'),
179
+ LOCAL_SKILLS_SEGMENTS.join('/'),
180
+ ]);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * lib/story-adjacency.js — the single story-level adjacency builder.
3
3
  *
4
- * Both pre-v2 Epic wave wrappers and the v2 `/deliver` ready-set path bottom out in the shared
4
+ * Both pre-v2 Epic wave wrappers and the v2 `/mandrel-deliver` ready-set path bottom out in the shared
5
5
  * `lib/Graph.js` kernel (`detectCycle` / `assignLayers` / `computeWaves`),
6
6
  * but each historically re-implemented the step that turns a list of Story
7
7
  * records into the `Map<storyId, number[]>` adjacency the kernel consumes.
@@ -43,7 +43,7 @@ import { parseBlockedBy } from './dependency-parser.js';
43
43
  * DAG nodes).
44
44
  * @param {object} [opts]
45
45
  * @param {boolean} [opts.dropForeign=false] When `false` (the v2 default,
46
- * matching the `/deliver` path — `stories-wave-tick.js` and the
46
+ * matching the `/mandrel-deliver` path — `stories-wave-tick.js` and the
47
47
  * `planReadySet` core), the operator-DAG contract is preserved: a
48
48
  * dependency on an id absent from the input is treated as not-yet-done
49
49
  * and withholds the dependent until it completes. When `true` (the
@@ -21,7 +21,7 @@ import { BODY_FORMAT_LINTS } from '../story-body/body-format-lints.js';
21
21
  *
22
22
  * **Single source of the prompt body (Story #4162).** This module is the sole
23
23
  * carrier of the full decomposer system-prompt body, delivered to the host
24
- * LLM in the `systemPrompts.decompose` field of the `/plan` context envelope
24
+ * LLM in the `systemPrompts.decompose` field of the `/mandrel-plan` context envelope
25
25
  * (via `lib/orchestration/planning/decomposer-context.js`), so no second
26
26
  * verbatim copy can drift.
27
27
  */
@@ -12,7 +12,7 @@
12
12
  * Section-scoped writes are the load-bearing contract (extending the
13
13
  * single-writer discipline #4303 established for the body trailer):
14
14
  *
15
- * - The `/plan` persist path upserts the `techSpec` and `acceptanceTable`
15
+ * - The `/mandrel-plan` persist path upserts the `techSpec` and `acceptanceTable`
16
16
  * regions.
17
17
  * - The close-time acceptance reconciler
18
18
  * (`acceptance-spec-reconciler.js`) reads and rewrites the
@@ -16,7 +16,7 @@
16
16
  /**
17
17
  * The one bound every independent-write fan-out over the GitHub API uses
18
18
  * (Story #4952 raised those loops off serial; Story #4961 made this the single
19
- * owner of the number they share). Imported by the `/plan` context gathers,
19
+ * owner of the number they share). Imported by the `/mandrel-plan` context gathers,
20
20
  * the persist checkpoint fan-out, the `agent::ready` flips and the supersede
21
21
  * close loop, so re-tuning the policy is one edit rather than six.
22
22
  *
@@ -2,7 +2,7 @@
2
2
  * parse-id-list — expand a Story-id list that may contain dash ranges.
3
3
  *
4
4
  * Operators name a contiguous span of Stories the way they read one — as a
5
- * range: `/deliver 4922 - 4926`. Enumerating it by hand is the kind of
5
+ * range: `/mandrel-deliver 4922 - 4926`. Enumerating it by hand is the kind of
6
6
  * transcription step that silently drops or invents an id, so the range is a
7
7
  * first-class shape of every delivery id list rather than something the host
8
8
  * expands from prose.
@@ -7,7 +7,7 @@
7
7
  * in-flight count, and it decides. Until now the only adapter was the
8
8
  * flag-driven one (`stories-wave-tick.js --dag/--done/--in-flight`), which
9
9
  * pushed the *gathering* of those inputs onto the caller — in practice onto
10
- * the host LLM following `/deliver`'s prose, re-seeding `--done` and counting
10
+ * the host LLM following `/mandrel-deliver`'s prose, re-seeding `--done` and counting
11
11
  * `--in-flight` by hand every beat. That is hand-maintained accounting on the
12
12
  * one correctness-critical path where a mistake silently wedges a run (a
13
13
  * dropped foreign blocker) or double-dispatches a Story (a miscounted slot).
@@ -47,7 +47,7 @@
47
47
  * The graph resolution is **not** reimplemented here — it reuses
48
48
  * `resolve-stories.js`'s machinery wholesale (body `depends_on` ∪ native
49
49
  * `blocked_by` edges, foreign-blocker resolution, `files[]` footprints), so
50
- * the probe and `/deliver`'s step-1 resolution cannot disagree about what
50
+ * the probe and `/mandrel-deliver`'s step-1 resolution cannot disagree about what
51
51
  * depends on what.
52
52
  *
53
53
  * @module lib/wave-runner/live-probe
@@ -127,7 +127,7 @@ function deriveInFlightIds(storyRecords, dispatched = []) {
127
127
  * in-flight, so `detectWedge` dropped it (its "undone work with no unmet
128
128
  * blockers would have been dispatched" invariant is precisely what probe mode
129
129
  * broke) and the beat reported exit 0 / `ready: []` / `wedged: null` forever.
130
- * `/deliver` reads that as "waiting", so the `agent::blocked` HITL pause — the
130
+ * `/mandrel-deliver` reads that as "waiting", so the `agent::blocked` HITL pause — the
131
131
  * one runtime gate in the protocol — was never surfaced to the operator.
132
132
  *
133
133
  * @param {Array<{id?: number, number?: number, labels?: string[], state?: string}>} storyRecords
@@ -193,7 +193,7 @@ function deriveForeignHeld(storyRecords, self, warn) {
193
193
  * Resolve the provider + repo coordinates the probe reads through.
194
194
  *
195
195
  * Shares `resolve-stories.js`'s provider seam, so probe mode authenticates and
196
- * targets exactly the same repo `/deliver`'s resolution step does. Tests
196
+ * targets exactly the same repo `/mandrel-deliver`'s resolution step does. Tests
197
197
  * inject a stub provider instead of calling this.
198
198
  *
199
199
  * @param {object} [deps]
@@ -2,7 +2,7 @@
2
2
  * lib/wave-runner/ready-set.js — the path-agnostic ready-set scheduling
3
3
  * core.
4
4
  *
5
- * This module is the scheduling kernel the v2 `/deliver` multi-Story path
5
+ * This module is the scheduling kernel the v2 `/mandrel-deliver` multi-Story path
6
6
  * dispatches through (`stories-wave-tick.js`). It replaces wave-*batch* selection
7
7
  * (group N must fully drain before group N+1 opens) with *continuous*,
8
8
  * dependency-driven selection: a Story becomes dispatchable the instant
@@ -345,7 +345,7 @@ function loadDocs(root, fs) {
345
345
  * (`helpers/deliver-story.md` → `# /deliver-story …`). The self-naming test is
346
346
  * what separates an invocable helper from an appendix that merely titles itself
347
347
  * after the command it documents (`helpers/deliver-reference.md` →
348
- * `# /deliver — reference appendix`), which is read on demand, never invoked.
348
+ * `# /mandrel-deliver — reference appendix`), which is read on demand, never invoked.
349
349
  *
350
350
  * @param {WorkflowDoc} doc
351
351
  * @returns {boolean}
@@ -7,7 +7,7 @@
7
7
  * on a Windows lock-class failure, the entry is appended to
8
8
  * `.worktrees/.pending-cleanup.json`. The plan-time `worktree-sweep.js`
9
9
  * reader (shipped in Epic #349) picks up the manifest on the next
10
- * `/plan` run and retries removal
10
+ * `/mandrel-plan` run and retries removal
11
11
  * (`git worktree remove` then `fs.rm`) — by then the live file handles from Node / AV / the Windows
12
12
  * Search indexer are almost always gone. If `MAX_SWEEP_ATTEMPTS` elapses
13
13
  * without clearing, an `OPERATOR ACTION REQUIRED: persistent-lock` line
@@ -17,7 +17,7 @@
17
17
  * git-ignored; no tracked state is mutated.
18
18
  *
19
19
  * **Story #5112 — the manifest is shared mutable state.** `recordPendingCleanup`
20
- * runs from the close reap and `drainPendingCleanup` runs from the `/plan`
20
+ * runs from the close reap and `drainPendingCleanup` runs from the `/mandrel-plan`
21
21
  * boot, so two concurrent delivery sessions can read-modify-write the same
22
22
  * file. Three properties close that:
23
23
  *
@@ -10,8 +10,8 @@
10
10
  * human files a ticket whose body `parse()` rejects (or which lacks the
11
11
  * binding `goal` / `acceptance` / `verify` sections), the lint surfaces a
12
12
  * **comment** on the issue rather than failing silently — the supported
13
- * human entry points (`/plan` from an existing Epic ID, the qa-assist →
14
- * `/plan` handoff) depend on a parseable body.
13
+ * human entry points (`/mandrel-plan` from an existing Epic ID, the qa-assist →
14
+ * `/mandrel-plan` handoff) depend on a parseable body.
15
15
  *
16
16
  * ## Design
17
17
  *
@@ -157,7 +157,7 @@ export function renderConformanceComment(verdict) {
157
157
  '### ⚠️ Ticket body does not round-trip through the Mandrel parser',
158
158
  '',
159
159
  'Agents build ticket bodies from a canonical schema that this body does ' +
160
- 'not match, so the supported human entry points (e.g. `/plan` from an ' +
160
+ 'not match, so the supported human entry points (e.g. `/mandrel-plan` from an ' +
161
161
  'existing Epic ID) will reject it. Please fix the following:',
162
162
  '',
163
163
  ...verdict.problems.map((p) => `- ${p}`),
@@ -2,9 +2,9 @@
2
2
  /* node:coverage ignore file */
3
3
 
4
4
  /**
5
- * plan-context.js — step 1 of the collapsed `/plan` pipeline.
5
+ * plan-context.js — step 1 of the collapsed `/mandrel-plan` pipeline.
6
6
  *
7
- * Emits one stdout-pure JSON envelope for the `/plan` authoring middle.
7
+ * Emits one stdout-pure JSON envelope for the `/mandrel-plan` authoring middle.
8
8
  *
9
9
  * Two operator modes (exactly one is required):
10
10
  *
@@ -25,7 +25,7 @@
25
25
  *
26
26
  * Flags:
27
27
  * --out <path> Write the envelope to <path> (parent dirs created).
28
- * `/plan` points this at `<plan-dir>/plan-context.json`,
28
+ * `/mandrel-plan` points this at `<plan-dir>/plan-context.json`,
29
29
  * which is where `plan-persist.js` auto-discovers the
30
30
  * `--tickets` source ids from (Story #4554). Without a
31
31
  * captured envelope persist cannot know a `--tickets` run
@@ -348,7 +348,7 @@ runAsCli(import.meta.url, main, {
348
348
  invocation:
349
349
  'node .agents/scripts/plan-context.js (--seed "<text>" | --seed-file <path> | --tickets <ids> | --amends <id>) [--out <path>] [--pretty]',
350
350
  summary:
351
- 'Build the /plan authoring-context envelope on stdout. Exactly one entry form must be supplied.',
351
+ 'Build the /mandrel-plan authoring-context envelope on stdout. Exactly one entry form must be supplied.',
352
352
  flags: [
353
353
  ['--seed "<text>"', 'Inline seed prose.'],
354
354
  ['--seed-file <path>', 'Seed document to read.'],
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * plan-critics.js — the /plan critic-dispatch verdict CLI (Story #4592).
4
+ * plan-critics.js — the /mandrel-plan critic-dispatch verdict CLI (Story #4592).
5
5
  *
6
- * `/plan` step 2.5 (between Author and Persist) runs this against the draft
6
+ * `/mandrel-plan` step 2.5 (between Author and Persist) runs this against the draft
7
7
  * `stories.json`. It evaluates the consolidation + pre-mortem dispatch
8
8
  * conditions and prints the verdict as JSON on stdout so the workflow can
9
9
  * act on it — dispatching a fresh-context critic sub-agent and folding its
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * plan-persist.js — flat Story GitHub-write surface for v2 `/plan`
4
+ * plan-persist.js — flat Story GitHub-write surface for v2 `/mandrel-plan`
5
5
  * (Stage 3 — `docs/roadmap.md`).
6
6
  *
7
7
  * Given the author-written planning artifacts (`stories.json`, optional shared
@@ -201,7 +201,7 @@ async function loadArtifacts(paths) {
201
201
  *
202
202
  * Exported for tests: this is the join where the envelope-derived source ids
203
203
  * meet the persist engine, so a regression here silently un-wires
204
- * `/plan --tickets` superseding (Story #4554).
204
+ * `/mandrel-plan --tickets` superseding (Story #4554).
205
205
  *
206
206
  * @param {object} values Parsed `parseArgs` values.
207
207
  * @param {ReturnType<typeof resolveInputPaths>} paths