mandrel 2.64.0 → 2.66.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 (105) hide show
  1. package/.agents/agents/acceptance-critic.md +8 -7
  2. package/.agents/agents/auditor.md +20 -20
  3. package/.agents/agents/plan-critic.md +8 -7
  4. package/.agents/agents/story-worker.md +7 -7
  5. package/.agents/audit-checklists/quality.md +3 -0
  6. package/.agents/docs/agentrc-reference.json +1 -9
  7. package/.agents/docs/configuration.md +8 -7
  8. package/.agents/docs/execution-reference.md +27 -5
  9. package/.agents/instructions.md +10 -12
  10. package/.agents/rules/ci-remediation.md +3 -3
  11. package/.agents/rules/gherkin-standards.md +3 -2
  12. package/.agents/rules/git-conventions-reference.md +12 -3
  13. package/.agents/rules/git-conventions.md +9 -7
  14. package/.agents/rules/testing-standards.md +8 -7
  15. package/.agents/runtime-deps.json +1 -1
  16. package/.agents/schemas/agentrc.schema.json +6 -13
  17. package/.agents/schemas/audit-rules.schema.json +1 -1
  18. package/.agents/schemas/story-deliver-terminal.schema.json +5 -0
  19. package/.agents/scripts/bootstrap.js +102 -91
  20. package/.agents/scripts/check-context-budget.js +1 -1
  21. package/.agents/scripts/lib/ITicketingProvider.js +1 -3
  22. package/.agents/scripts/lib/audit-suite/findings.js +1 -17
  23. package/.agents/scripts/lib/audit-suite/frontmatter.js +0 -28
  24. package/.agents/scripts/lib/audit-suite/index.js +0 -6
  25. package/.agents/scripts/lib/audit-suite/selector.js +0 -31
  26. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  27. package/.agents/scripts/lib/bootstrap/agents-md-fold.js +156 -0
  28. package/.agents/scripts/lib/bootstrap/commit-push.js +1 -1
  29. package/.agents/scripts/lib/bootstrap/manifest.js +2 -2
  30. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +91 -107
  31. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  32. package/.agents/scripts/lib/cli-args.js +26 -0
  33. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  34. package/.agents/scripts/lib/config/review-chain-default.js +13 -0
  35. package/.agents/scripts/lib/config-settings-schema-delivery.js +2 -2
  36. package/.agents/scripts/lib/config-settings-schema-quality.js +11 -13
  37. package/.agents/scripts/lib/doc-tiers.js +25 -6
  38. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  39. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  40. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  43. package/.agents/scripts/lib/observability/metrics-ledger.js +0 -72
  44. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  45. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  46. package/.agents/scripts/lib/orchestration/code-review.js +33 -6
  47. package/.agents/scripts/lib/orchestration/epic-rollup.js +29 -12
  48. package/.agents/scripts/lib/orchestration/merge-block-class.js +20 -4
  49. package/.agents/scripts/lib/orchestration/merge-poll.js +41 -22
  50. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  51. package/.agents/scripts/lib/orchestration/required-checks.js +147 -0
  52. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +203 -0
  53. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +29 -4
  54. package/.agents/scripts/lib/orchestration/review-providers/security-review.js +3 -2
  55. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  56. package/.agents/scripts/lib/orchestration/single-story-close/failed-terminal.js +1 -0
  57. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -12
  58. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +370 -268
  59. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  60. package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +112 -82
  61. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  62. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +393 -313
  63. package/.agents/scripts/lib/orchestration/story-close/phases/review-core.js +12 -87
  64. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +3 -0
  65. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  66. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  67. package/.agents/scripts/lib/templates/decomposer-prompts.js +5 -24
  68. package/.agents/scripts/lib/transpile.js +28 -3
  69. package/.agents/scripts/providers/github/issues.js +14 -23
  70. package/.agents/scripts/single-story-close.js +10 -2
  71. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  72. package/.agents/scripts/sync-claude-agents.js +1 -1
  73. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  74. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  75. package/.agents/workflows/audit-architecture.md +5 -4
  76. package/.agents/workflows/audit-documentation.md +5 -5
  77. package/.agents/workflows/audit-performance.md +10 -10
  78. package/.agents/workflows/audit-quality.md +42 -7
  79. package/.agents/workflows/helpers/acceptance-self-eval.md +9 -9
  80. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  81. package/.agents/workflows/helpers/code-review.md +15 -38
  82. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  83. package/.agents/workflows/helpers/deliver-reference.md +7 -3
  84. package/.agents/workflows/helpers/deliver-story.md +9 -1
  85. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  86. package/.agents/workflows/helpers/plan-reference.md +9 -8
  87. package/.agents/workflows/mandrel-deliver.md +3 -2
  88. package/.agents/workflows/mandrel-plan.md +11 -7
  89. package/.agents/workflows/mandrel-update.md +5 -3
  90. package/docs/CHANGELOG.md +57 -0
  91. package/lib/cli/claude-code-version.js +73 -0
  92. package/lib/cli/doctor.js +2 -2
  93. package/lib/cli/guarded-sync.js +87 -0
  94. package/lib/cli/registry.js +9 -0
  95. package/lib/cli/sync-agents.js +9 -92
  96. package/lib/cli/sync-commands.js +9 -101
  97. package/lib/cli/uninstall.js +37 -9
  98. package/lib/migrations/index.js +2 -0
  99. package/lib/migrations/steps/2.65.0-fold-claude-md-into-agents-md.js +38 -0
  100. package/package.json +3 -2
  101. package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +0 -99
  102. package/.agents/scripts/lib/audit-suite/runner.js +0 -205
  103. package/.agents/scripts/lib/audit-suite/substitutions.js +0 -96
  104. package/.agents/scripts/lib/audit-suite/workflow-loader.js +0 -37
  105. package/.agents/scripts/lib/orchestration/story-close/phases/local-lens-review.js +0 -234
@@ -3,23 +3,20 @@
3
3
  * maker-blind `runCodeReview` invocation out of any phase file.
4
4
  */
5
5
 
6
- import { countChangedLines } from '../../../audit-suite/index.js';
7
6
  import { gitSpawn } from '../../../git-utils.js';
8
- import { appendFindingsYield } from '../../../observability/metrics-ledger.js';
9
7
  import { computeChangeSet } from '../../change-set.js';
10
8
  import { runCodeReview } from '../../code-review.js';
11
- import { runLocalLensReview } from './local-lens-review.js';
12
9
 
13
10
  /**
14
- * Run the local-lens pass and `runCodeReview` over one change set and return
15
- * the review result. Throws propagate; the caller picks the advisory posture.
16
- * Review depth is derived by `runCodeReview` from the changed files and is
17
- * input-only — it never alters the output envelope.
11
+ * Run `runCodeReview` over one change set and return the review result.
12
+ * Throws propagate; the caller picks the advisory posture. Review depth is
13
+ * derived by `runCodeReview` from the changed files and is input-only — it
14
+ * never alters the output envelope.
18
15
  *
19
- * The diff is enumerated exactly once here and injected into both consumers,
20
- * so lens roster and review depth agree on what changed even if a commit
21
- * lands in between. An unenumerable diff injects `null` ("already tried"),
22
- * which both consumers honour without re-spawning git.
16
+ * The diff is enumerated exactly once here and injected into the review, so
17
+ * review depth scores the same file set the change set names even if a
18
+ * commit lands in between. An unenumerable diff injects `null` ("already
19
+ * tried"), which the review honours without re-spawning git.
23
20
  *
24
21
  * @param {{
25
22
  * storyId: number|string,
@@ -32,12 +29,9 @@ import { runLocalLensReview } from './local-lens-review.js';
32
29
  * gitSpawnFn?: import('../../change-set.js').GitSpawnFn,
33
30
  * computeChangeSetFn?: typeof computeChangeSet,
34
31
  * runCodeReviewFn?: typeof runCodeReview,
35
- * runLocalLensReviewFn?: typeof runLocalLensReview,
36
- * countChangedLinesFn?: typeof countChangedLines,
37
- * appendFindingsYieldFn?: typeof appendFindingsYield,
38
32
  * }} args
39
- * @returns {Promise<object>} The `runCodeReview` result plus
40
- * `localLensReview` and the computed `changeSet`.
33
+ * @returns {Promise<object>} The `runCodeReview` result plus the computed
34
+ * `changeSet`.
41
35
  */
42
36
  export async function runStoryReviewCore({
43
37
  storyId,
@@ -50,24 +44,12 @@ export async function runStoryReviewCore({
50
44
  gitSpawnFn = gitSpawn,
51
45
  computeChangeSetFn = computeChangeSet,
52
46
  runCodeReviewFn = runCodeReview,
53
- runLocalLensReviewFn = runLocalLensReview,
54
- countChangedLinesFn = countChangedLines,
55
- appendFindingsYieldFn = appendFindingsYield,
56
47
  }) {
57
- const storyIdNum = Number(storyId);
58
-
59
48
  const changeSet = computeChangeSetFn({ baseRef, headRef, gitSpawnFn });
60
49
 
61
- // Line count for the lens diff-floor, probed only for a non-empty file set;
62
- // `null` = unknown, and the floor fails open.
63
- const changedLineCount =
64
- Array.isArray(changeSet.files) && changeSet.files.length > 0
65
- ? countChangedLinesFn({ baseRef, headRef, gitSpawnFn })
66
- : null;
67
-
68
50
  const opts = {
69
51
  scope: 'story',
70
- ticketId: storyIdNum,
52
+ ticketId: Number(storyId),
71
53
  baseRef,
72
54
  headRef,
73
55
  provider,
@@ -82,63 +64,6 @@ export async function runStoryReviewCore({
82
64
  opts.commentTargetId = commentTargetId;
83
65
  }
84
66
 
85
- const localLensReview = await runLocalLensReviewFn({
86
- baseRef,
87
- headRef,
88
- changedFiles: changeSet.files,
89
- changedLineCount,
90
- storyId: storyIdNum,
91
- progress,
92
- progressTag,
93
- gitSpawnFn,
94
- });
95
-
96
67
  const result = await runCodeReviewFn(opts);
97
-
98
- // Best-effort findings-yield ledger, for tuning the roster on measurement.
99
- try {
100
- const yieldEntries = buildLensYieldEntries(localLensReview);
101
- if (yieldEntries !== null) {
102
- await appendFindingsYieldFn({
103
- storyId: storyIdNum,
104
- cli: 'story-close-review',
105
- lenses: yieldEntries,
106
- diffFloor: localLensReview?.floorSkip ?? null,
107
- });
108
- }
109
- } catch (err) {
110
- progress(
111
- progressTag,
112
- `⚠️ findings-yield ledger append failed (continuing): ${err?.message ?? err}`,
113
- );
114
- }
115
-
116
- return { ...result, localLensReview, changeSet };
117
- }
118
-
119
- /**
120
- * One findings-yield entry per matched lens; `null` for an empty roster.
121
- *
122
- * @param {object|null|undefined} localLensReview
123
- * @returns {Array<{ lens: string, findings: number, skippedByFloor: boolean }>|null}
124
- */
125
- function buildLensYieldEntries(localLensReview) {
126
- const lenses = Array.isArray(localLensReview?.lenses)
127
- ? localLensReview.lenses.filter((l) => typeof l === 'string' && l.length)
128
- : [];
129
- if (lenses.length === 0) return null;
130
- const skippedByFloor = localLensReview?.floorSkip?.skip === true;
131
- const findingsByLens = new Map();
132
- for (const finding of localLensReview?.materialized?.findings ?? []) {
133
- if (typeof finding?.audit !== 'string') continue;
134
- findingsByLens.set(
135
- finding.audit,
136
- (findingsByLens.get(finding.audit) ?? 0) + 1,
137
- );
138
- }
139
- return lenses.map((lens) => ({
140
- lens,
141
- findings: skippedByFloor ? 0 : (findingsByLens.get(lens) ?? 0),
142
- skippedByFloor,
143
- }));
68
+ return { ...result, changeSet };
144
69
  }
@@ -116,6 +116,7 @@ function compact(obj) {
116
116
  * @param {object|null} [args.waitBudget]
117
117
  * @param {{ waitedSeconds: number, expired: boolean }|null} [args.lockWait]
118
118
  * Full-suite lock wait; `waitBudget` is merge-wait only.
119
+ * @param {Record<string, number>|null} [args.phaseDurations] Seconds per phase.
119
120
  * @param {string} [args.timestamp]
120
121
  * @param {{ schema: object|null, error: string|null }} [args.schemaSource]
121
122
  * Test seam.
@@ -137,6 +138,7 @@ export function buildTerminalEnvelope({
137
138
  elapsedSeconds = 0,
138
139
  waitBudget,
139
140
  lockWait,
141
+ phaseDurations,
140
142
  timestamp = new Date().toISOString(),
141
143
  schemaSource,
142
144
  }) {
@@ -158,6 +160,7 @@ export function buildTerminalEnvelope({
158
160
  elapsedSeconds: Math.max(0, Number(elapsedSeconds) || 0),
159
161
  waitBudget: waitBudget ?? null,
160
162
  lockWait: lockWait ?? null,
163
+ phaseDurations,
161
164
  timestamp,
162
165
  });
163
166
 
@@ -60,49 +60,32 @@ function collectTaskPathReferences(task) {
60
60
  */
61
61
  function collectTaskChangesPaths(task) {
62
62
  const paths = new Set();
63
- const body = task.body;
63
+ const source = resolveChangesSource(task);
64
+ if (source === null) return paths;
65
+ for (const arrName of ['changes', 'references']) {
66
+ const arr = source[arrName];
67
+ if (!Array.isArray(arr)) continue;
68
+ for (const item of arr) collectChangesItem(item, paths);
69
+ }
70
+ return paths;
71
+ }
64
72
 
73
+ function resolveChangesSource(task) {
74
+ const body = task.body;
65
75
  // A parse failure throws rather than yielding an empty whitelist, which
66
76
  // would misreport every declared path as missing.
67
77
  if (typeof body === 'string' && body.trim().length > 0) {
68
- const parsed = parseStoryBodyOrThrow(task);
69
- for (const arrName of ['changes', 'references']) {
70
- const arr = parsed[arrName];
71
- if (!Array.isArray(arr)) continue;
72
- for (const item of arr) {
73
- if (typeof item === 'string') {
74
- collectPathsFromText(item, paths);
75
- } else if (
76
- item !== null &&
77
- typeof item === 'object' &&
78
- typeof item.path === 'string' &&
79
- item.path.length > 0
80
- ) {
81
- paths.add(item.path);
82
- }
83
- }
84
- }
85
- return paths;
78
+ return parseStoryBodyOrThrow(task);
86
79
  }
80
+ return body !== null && typeof body === 'object' ? body : null;
81
+ }
87
82
 
88
- if (body === null || typeof body !== 'object') return paths;
89
- for (const arrName of ['changes', 'references']) {
90
- const arr = body[arrName];
91
- if (!Array.isArray(arr)) continue;
92
- for (const item of arr) {
93
- if (typeof item === 'string') {
94
- collectPathsFromText(item, paths);
95
- } else if (
96
- item !== null &&
97
- typeof item === 'object' &&
98
- typeof item.path === 'string' &&
99
- item.path.length > 0
100
- ) {
101
- paths.add(item.path);
102
- }
103
- }
83
+ function collectChangesItem(item, paths) {
84
+ if (typeof item === 'string') {
85
+ collectPathsFromText(item, paths);
86
+ } else if (typeof item?.path === 'string' && item.path.length > 0) {
87
+ paths.add(item.path);
104
88
  }
105
- return paths;
106
89
  }
107
90
 
108
91
  function defaultGitRunner({ baseBranchRef, path, cwd }) {
@@ -51,9 +51,12 @@ export function extractTool(rec) {
51
51
  */
52
52
  export function validateDetectorArgs(args, opts) {
53
53
  const { fnName } = opts;
54
- const requireTracesPath = opts.requireTracesPath ?? true;
55
- const requireStoryId = opts.requireStoryId ?? true;
56
- const requireThreshold = opts.requireThreshold ?? true;
54
+ const gates = {
55
+ always: true,
56
+ requireTracesPath: opts.requireTracesPath ?? true,
57
+ requireStoryId: opts.requireStoryId ?? true,
58
+ requireThreshold: opts.requireThreshold ?? true,
59
+ };
57
60
 
58
61
  if (args == null || typeof args !== 'object') {
59
62
  throw new TypeError(
@@ -61,57 +64,66 @@ export function validateDetectorArgs(args, opts) {
61
64
  );
62
65
  }
63
66
 
64
- const { tracesPath, epicId, storyId, threshold } = args;
65
- const taskId = args.taskId ?? null;
66
-
67
- if (args.nowFn != null && typeof args.nowFn !== 'function') {
68
- throw new TypeError(
69
- `${fnName}: nowFn, when provided, must be a function (got ${typeof args.nowFn})`,
70
- );
71
- }
72
- const nowFn = args.nowFn ?? (() => new Date().toISOString());
73
-
74
- if (requireTracesPath) {
75
- if (typeof tracesPath !== 'string' || tracesPath.length === 0) {
76
- throw new TypeError(
77
- `${fnName}: tracesPath must be a non-empty string (got ${tracesPath})`,
78
- );
79
- }
80
- }
81
-
82
- if (!isPositiveInt(epicId)) {
83
- throw new RangeError(
84
- `${fnName}: epicId must be a positive integer (got ${epicId})`,
85
- );
86
- }
87
-
88
- if (requireStoryId) {
89
- if (!isPositiveInt(storyId)) {
90
- throw new RangeError(
91
- `${fnName}: storyId must be a positive integer (got ${storyId})`,
92
- );
93
- }
94
- if (taskId !== null && !isPositiveInt(taskId)) {
95
- throw new RangeError(
96
- `${fnName}: taskId must be a positive integer or null (got ${taskId})`,
97
- );
98
- }
99
- }
100
-
101
- if (requireThreshold) {
102
- if (!Number.isInteger(threshold) || threshold < 0) {
103
- throw new RangeError(
104
- `${fnName}: threshold must be a non-negative integer (got ${threshold})`,
105
- );
67
+ const values = { ...args, taskId: args.taskId ?? null };
68
+ for (const rule of DETECTOR_ARG_RULES) {
69
+ const value = values[rule.field];
70
+ if (gates[rule.gate] && rule.invalid(value)) {
71
+ throw new rule.Error(`${fnName}: ${rule.message(value)}`);
106
72
  }
107
73
  }
108
74
 
109
75
  return {
110
- tracesPath: requireTracesPath ? tracesPath : undefined,
111
- epicId,
112
- storyId: requireStoryId ? storyId : undefined,
113
- taskId: requireStoryId ? taskId : undefined,
114
- threshold: requireThreshold ? threshold : undefined,
115
- nowFn,
76
+ tracesPath: gates.requireTracesPath ? values.tracesPath : undefined,
77
+ epicId: values.epicId,
78
+ storyId: gates.requireStoryId ? values.storyId : undefined,
79
+ taskId: gates.requireStoryId ? values.taskId : undefined,
80
+ threshold: gates.requireThreshold ? values.threshold : undefined,
81
+ nowFn: args.nowFn ?? (() => new Date().toISOString()),
116
82
  };
117
83
  }
84
+
85
+ const DETECTOR_ARG_RULES = [
86
+ {
87
+ field: 'nowFn',
88
+ gate: 'always',
89
+ invalid: (v) => v != null && typeof v !== 'function',
90
+ Error: TypeError,
91
+ message: (v) =>
92
+ `nowFn, when provided, must be a function (got ${typeof v})`,
93
+ },
94
+ {
95
+ field: 'tracesPath',
96
+ gate: 'requireTracesPath',
97
+ invalid: (v) => typeof v !== 'string' || v.length === 0,
98
+ Error: TypeError,
99
+ message: (v) => `tracesPath must be a non-empty string (got ${v})`,
100
+ },
101
+ {
102
+ field: 'epicId',
103
+ gate: 'always',
104
+ invalid: (v) => !isPositiveInt(v),
105
+ Error: RangeError,
106
+ message: (v) => `epicId must be a positive integer (got ${v})`,
107
+ },
108
+ {
109
+ field: 'storyId',
110
+ gate: 'requireStoryId',
111
+ invalid: (v) => !isPositiveInt(v),
112
+ Error: RangeError,
113
+ message: (v) => `storyId must be a positive integer (got ${v})`,
114
+ },
115
+ {
116
+ field: 'taskId',
117
+ gate: 'requireStoryId',
118
+ invalid: (v) => v !== null && !isPositiveInt(v),
119
+ Error: RangeError,
120
+ message: (v) => `taskId must be a positive integer or null (got ${v})`,
121
+ },
122
+ {
123
+ field: 'threshold',
124
+ gate: 'requireThreshold',
125
+ invalid: (v) => !Number.isInteger(v) || v < 0,
126
+ Error: RangeError,
127
+ message: (v) => `threshold must be a non-negative integer (got ${v})`,
128
+ },
129
+ ];
@@ -31,13 +31,11 @@ export function renderStoryAuthorCore() {
31
31
  (lint) =>
32
32
  `- **${lint.id}** — ${lint.summary} Example: \`${lint.goodExample}\``,
33
33
  ).join('\n');
34
- return `You are an expert Senior Project Manager and Orchestrator.
35
- Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI Agent to execute.
34
+ return `Turn a plan seed / Tech Spec into Story tickets for an AI agent to execute. The emitted stories template (see STORY BODY SCHEMA) is the ticket shape.
36
35
 
37
36
  ### HIERARCHY RULES (v2 default-single):
38
37
  1. **Emit exactly one Story by default.** Split into N>1 only when pieces have near-zero overlap or sit across an architectural seam. Coupled work stays one Story — put intra-session checkpoints in \`## Slicing\` and fold the Tech Spec into \`## Spec\`.
39
38
  2. **Stories**: Specific user-facing or architectural capabilities (e.g., "Implement JWT Token Exchange").
40
- - There is NO Epic parent ticket, NO Feature tier, and NO Task layer.
41
39
  - **Story-Level Execution**: Each Story is executed end-to-end on a single branch by a single agent. Acceptance criteria and verification commands live as top-level \`acceptance[]\` / \`verify[]\` arrays on the Story ticket (see STORY BODY SCHEMA below).
42
40
  - Thematic grouping is prose in the Story's folded \`## Spec\` / \`## Slicing\`, never sibling tickets for coupled work.
43
41
 
@@ -46,27 +44,10 @@ Your job is to turn a plan seed / Tech Spec into a Story ticket array for an AI
46
44
  - \`labels[]\` is **optional**. Emit it only to request an *additional* label; persist sanitizes the list before applying it.
47
45
  - Do **not** emit \`agent::*\` labels — lifecycle state is runtime-owned, and persist applies \`agent::ready\` itself once every checkpoint is on the ticket.
48
46
 
49
- ### OUTPUT FORMAT:
50
- You MUST respond ONLY with a valid JSON array of objects. No prose, no markdown blocks.
51
-
52
- ### JSON SCHEMA:
53
- [
54
- {
55
- "slug": "hyphen-case-id",
56
- "type": "story",
57
- "title": "Short descriptive title",
58
- "body": <string — see STORY BODY SCHEMA below>,
59
- "acceptance": ["<outcome a PR reviewer can confirm>", ...],
60
- "verify": ["<exact command or test path>", ...],
61
- "labels": ["<extra-label>"] (optional — type::story is applied automatically; omit this field unless you need an additional label),
62
- "depends_on": ["slug-of-blocking-dependency"] (optional array of Story slugs that block execution)
63
- }
64
- ]
65
-
66
47
  **Slug format**: \`^[a-z0-9][a-z0-9-]*$\` — hyphen-case only. Underscores are rejected by the validator.
67
48
 
68
49
  ### STORY BODY SCHEMA (REQUIRED FOR EVERY STORY):
69
- \`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). Stories are consumed by non-interactive sub-agents that must self-verify from the Story ticket alone — so the ticket must carry everything an agent needs to execute and self-verify.
50
+ \`body\` is either the serialized markdown **string** (the section format below) or a **structured object** carrying the same fields (\`goal\`, optional \`slicing\` / \`spec\`, \`changes\`, optional \`non_goals\`) — persist parses either shape and serializes the canonical markdown itself, so you never need to read \`story-body.js\` or hand-assemble the markdown (the \`stories.template.json\` file emitted next to the plan-context envelope is a ready-to-fill structured-object skeleton). The executing sub-agent is non-interactive and self-verifies from the ticket alone, so the ticket carries everything it needs.
70
51
 
71
52
  The \`acceptance[]\` and \`verify[]\` arrays live at the **top level** of the Story ticket object — that is the machine contract the validator reads. Author each list **once, at top level**, and **omit** the \`## Acceptance\` / \`## Verify\` sections from the authored \`body\` string: persist syncs the top-level arrays into those sections so the GitHub issue stays a complete executable document. The validator resolves both fields from the top level, so an omitted section is the expected shape, not a violation.
72
53
 
@@ -153,9 +134,9 @@ ${envelopeFloor}
153
134
  - A Story touching UI (\`*.tsx\`, \`*.astro\`, \`*.svelte\`, \`*.vue\`, a components folder) states the \`data-testid\` contract in \`acceptance[]\` per the testid contract in \`.agents/skills/stack/qa/playwright/SKILL.md\`.
154
135
  - A Story touching user-visible copy, brand assets or visual style cites the relevant section of \`docs/style-guide.md\` in \`acceptance[]\` when that file exists.
155
136
 
156
- CRITICAL: Dependencies should follow execution blockers. There is no parent ticket — never emit a 'parent_slug' field.
157
- IMPORTANT DEPENDENCY RULE: Story-to-Story dependencies are expressed via \`depends_on\` (one Story depends_on another Story's slug). Use this to express execution ordering across the plan.
158
- **Never stop mid-array.** Always emit complete JSON — partial arrays are rejected by the validator.`;
137
+ #### ORDERING:
138
+
139
+ Express execution ordering between Stories with \`depends_on\` — the slugs of the Stories that must land first. Never emit a parent field.`;
159
140
  }
160
141
 
161
142
  /**
@@ -8,6 +8,9 @@ const require = createRequire(import.meta.url);
8
8
 
9
9
  const TS_EXTS = new Set(['.ts', '.tsx', '.mts', '.cts']);
10
10
 
11
+ /** TS 7 moved `transpileModule` under `unstable/*`; mirrors the peer range. */
12
+ const SUPPORTED_TS_RANGE = '>=5.0.0 <7';
13
+
11
14
  let _ts = null;
12
15
  let _tsLoadFailed = false;
13
16
 
@@ -23,6 +26,27 @@ function loadTypeScript() {
23
26
  }
24
27
  }
25
28
 
29
+ /** Compiler modules already diagnosed as API-less — one warning per module. */
30
+ const _unsupportedDiagnosed = new WeakSet();
31
+
32
+ /**
33
+ * Warn once per API-less module rather than once per file.
34
+ *
35
+ * @param {object} ts
36
+ * @returns {boolean}
37
+ */
38
+ function isUsableCompiler(ts) {
39
+ if (typeof ts.transpileModule === 'function') return true;
40
+ if (!_unsupportedDiagnosed.has(ts)) {
41
+ _unsupportedDiagnosed.add(ts);
42
+ Logger.warn(
43
+ `[Maintainability] ⚠ typescript ${ts.version ?? 'unknown'} exposes no transpileModule API; ` +
44
+ `TypeScript files are not scored. Supported range: ${SUPPORTED_TS_RANGE}.`,
45
+ );
46
+ }
47
+ return false;
48
+ }
49
+
26
50
  let _tsVersion = null;
27
51
 
28
52
  /**
@@ -100,7 +124,7 @@ function buildLineMapper(sourceMapText, code) {
100
124
  *
101
125
  * @param {string} filePath
102
126
  * @param {string} source
103
- * @param {{withLineMap?: boolean}} [opts]
127
+ * @param {{withLineMap?: boolean, typescript?: object}} [opts]
104
128
  * @returns {string|null|{code: string, mapLine: ((line: number) => number|null)|null}}
105
129
  */
106
130
  export function transpileIfNeeded(filePath, source, opts = {}) {
@@ -108,14 +132,15 @@ export function transpileIfNeeded(filePath, source, opts = {}) {
108
132
  if (!isTypeScriptPath(filePath)) {
109
133
  return withLineMap ? { code: source, mapLine: null } : source;
110
134
  }
111
- const ts = loadTypeScript();
135
+ const ts = opts?.typescript ?? loadTypeScript();
112
136
  if (!ts) {
113
137
  Logger.warn(
114
138
  `[Maintainability] ⚠ typescript package not resolvable; cannot score ${filePath}. ` +
115
- "Install with 'npm install --save-dev typescript' (peer dep, >=5.0.0).",
139
+ `Install with 'npm install --save-dev typescript' (peer dep, ${SUPPORTED_TS_RANGE}).`,
116
140
  );
117
141
  return null;
118
142
  }
143
+ if (!isUsableCompiler(ts)) return null;
119
144
  try {
120
145
  const result = ts.transpileModule(source, {
121
146
  compilerOptions: {
@@ -138,38 +138,29 @@ export class IssuesGateway {
138
138
 
139
139
  /**
140
140
  * Resolve an issue's container parent in one request via `Issue.parent`.
141
- * Never throws: no parent, an odd shape, or sub-issues being unavailable all
142
- * return `null`, leaving the caller's checklist fallback to run.
141
+ * `null` means "no parent"; a degraded lookup throws after retries.
143
142
  *
144
143
  * @param {number} number Issue number whose parent to resolve.
145
144
  * @returns {Promise<object|null>} Mapped parent ticket, or null.
145
+ * @throws {Error} When the lookup degrades.
146
146
  * @field-manifest GraphQL Issue.parent: number, id, title, body, state,
147
147
  * labels.nodes.name, assignees.nodes.login
148
148
  */
149
149
  async getParentIssue(number) {
150
150
  const issueNumber = Number(number);
151
151
  if (!Number.isInteger(issueNumber) || issueNumber <= 0) return null;
152
- let data;
153
- try {
154
- data = await withTransientRetry(
155
- () =>
156
- this.ghGraphql(
157
- PARENT_ISSUE_QUERY,
158
- { owner: this.owner, repo: this.repo, number: issueNumber },
159
- { headers: { 'GraphQL-Features': 'sub_issues' } },
160
- ),
161
- {
162
- label: `getParentIssue #${issueNumber}`,
163
- onRetry: defaultRetryWarn,
164
- },
165
- );
166
- } catch (err) {
167
- Logger.warn(
168
- `[GitHubProvider] parent lookup for #${issueNumber} degraded to none ` +
169
- `(${err?.message ?? err}).`,
170
- );
171
- return null;
172
- }
152
+ const data = await withTransientRetry(
153
+ () =>
154
+ this.ghGraphql(
155
+ PARENT_ISSUE_QUERY,
156
+ { owner: this.owner, repo: this.repo, number: issueNumber },
157
+ { headers: { 'GraphQL-Features': 'sub_issues' } },
158
+ ),
159
+ {
160
+ label: `getParentIssue #${issueNumber}`,
161
+ onRetry: defaultRetryWarn,
162
+ },
163
+ );
173
164
  return subIssueNodeToTicket(data?.repository?.issue?.parent ?? null);
174
165
  }
175
166
 
@@ -16,7 +16,11 @@
16
16
  * [--merge-watch-mode <sync|async>]
17
17
  * [--rerun-advisory <n>]
18
18
  * [--override-review-block <reason>]
19
+ * [--worker-tokens <n>]
19
20
  *
21
+ * `--worker-tokens <n>` is the story-worker's host-reported token total,
22
+ * recorded best-effort in the close result's `telemetry` (absent or invalid →
23
+ * `null` plus a warning; never a change of status or exit code).
20
24
  * `--override-review-block <reason>` is the audited escape past a code-review
21
25
  * CRITICAL blocker (instead of a hand-merge with no record).
22
26
  * `--merge-watch-mode async` is passed per close by the orchestrator, the only
@@ -35,7 +39,7 @@ import { parseSprintArgsTolerant } from './lib/cli-args.js';
35
39
  import { runAsCli } from './lib/cli-utils.js';
36
40
  import { formatCliError } from './lib/error-redactor.js';
37
41
  import { Logger } from './lib/Logger.js';
38
- import { emitTerminalFriction } from './lib/observability/runtime-friction.js';
42
+ import { emitCloseTerminalSignals } from './lib/observability/close-telemetry.js';
39
43
  import { resolveRunScopedConfig } from './lib/orchestration/run-scoped-config.js';
40
44
  import {
41
45
  failedTerminalFor,
@@ -91,7 +95,7 @@ async function main() {
91
95
  // Mirrors runAsCli's default error line, which this catch pre-empts.
92
96
  Logger.error(`[single-story-close] Fatal error: ${formatCliError(err)}`);
93
97
  emitTerminalEnvelope(terminal);
94
- await emitTerminalFriction({ envelope: terminal });
98
+ await emitCloseTerminalSignals({ envelope: terminal });
95
99
  return exitCodeForTerminal(terminal);
96
100
  }
97
101
  }
@@ -130,6 +134,10 @@ runAsCli(import.meta.url, main, {
130
134
  // forbids that literal outside `phases/auto-merge.js`.
131
135
  'Land despite a Story-scope code-review CRITICAL blocker you have reviewed and judged wrong. The reason is mandatory (≥12 chars) and is recorded on the Story, on the PR, and as a `review-block-overridden` friction signal; the terminal envelope reports `gates.codeReview: "overridden"`. Use this instead of merging the PR by hand with the GitHub CLI — a hand-merge bypasses the gate with no record at all.',
132
136
  ],
137
+ [
138
+ '--worker-tokens <n>',
139
+ 'The story-worker’s host-reported total tokens, recorded in the close result’s `telemetry.workerTokens`. Best-effort: an absent or non-integer value records null with a warning and never changes the status or exit code.',
140
+ ],
133
141
  ],
134
142
  notes: [
135
143
  'Exit codes:\n 0 landed\n 1 blocked or failed\n 3 pending (resumable — run the envelope’s nextCommand)',