mandrel 1.87.0 → 1.89.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 +18 -13
- package/.agents/audit-checklists/architecture.md +24 -0
- package/.agents/audit-checklists/clean-code.md +24 -0
- package/.agents/audit-checklists/dependencies.md +14 -0
- package/.agents/audit-checklists/devops.md +17 -0
- package/.agents/audit-checklists/documentation.md +22 -0
- package/.agents/audit-checklists/lighthouse.md +15 -0
- package/.agents/audit-checklists/navigability.md +14 -0
- package/.agents/audit-checklists/performance.md +22 -0
- package/.agents/audit-checklists/privacy.md +21 -0
- package/.agents/audit-checklists/quality.md +18 -0
- package/.agents/audit-checklists/security.md +22 -0
- package/.agents/audit-checklists/seo.md +16 -0
- package/.agents/audit-checklists/sre.md +24 -0
- package/.agents/audit-checklists/ux-ui.md +21 -0
- package/.agents/docs/SDLC.md +63 -16
- package/.agents/docs/configuration.md +5 -3
- package/.agents/instructions.md +51 -21
- package/.agents/personas/architect.md +10 -7
- package/.agents/personas/engineer.md +4 -3
- package/.agents/personas/project-manager.md +5 -2
- package/.agents/personas/refactorer.md +5 -3
- package/.agents/rules/git-conventions.md +77 -0
- package/.agents/schemas/agentrc.schema.json +16 -4
- package/.agents/schemas/audit-rules.json +16 -2
- package/.agents/schemas/audit-rules.schema.json +7 -6
- package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
- package/.agents/schemas/signal-event.schema.json +28 -13
- package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
- package/.agents/scripts/check-context-budget.js +320 -0
- package/.agents/scripts/diagnose-friction.js +4 -4
- package/.agents/scripts/epic-audit-prepare.js +30 -2
- package/.agents/scripts/epic-audit-recheck.js +46 -13
- package/.agents/scripts/epic-deliver-prepare.js +80 -8
- package/.agents/scripts/epic-plan-spec.js +4 -8
- package/.agents/scripts/generate-lens-checklists.js +180 -0
- package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
- package/.agents/scripts/lib/audit-suite/findings.js +27 -0
- package/.agents/scripts/lib/audit-suite/index.js +9 -0
- package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
- package/.agents/scripts/lib/audit-suite/selector.js +136 -5
- package/.agents/scripts/lib/checks/loop-health.js +340 -0
- package/.agents/scripts/lib/cli-args.js +8 -0
- package/.agents/scripts/lib/config/explain.js +4 -0
- package/.agents/scripts/lib/config/runners.js +21 -2
- package/.agents/scripts/lib/config/temp-paths.js +24 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
- package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
- package/.agents/scripts/lib/doc-tiers.js +291 -0
- package/.agents/scripts/lib/epic-body-sections.js +5 -2
- package/.agents/scripts/lib/epic-merge-lock.js +83 -0
- package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
- package/.agents/scripts/lib/gates/friction.js +15 -5
- package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
- package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
- package/.agents/scripts/lib/observability/signal-validator.js +204 -0
- package/.agents/scripts/lib/observability/signals-writer.js +157 -54
- package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
- package/.agents/scripts/lib/orchestration/code-review.js +74 -4
- package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
- package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
- package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
- package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
- package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
- package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
- package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
- package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
- package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
- package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
- package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
- package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
- package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
- package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
- package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
- package/.agents/scripts/lib/planning-corpus.js +306 -0
- package/.agents/scripts/lib/signals/detectors/common.js +10 -10
- package/.agents/scripts/lib/signals/detectors/index.js +4 -4
- package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
- package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
- package/.agents/scripts/lib/signals/schema.js +56 -81
- package/.agents/scripts/lib/signals/span-tree.js +6 -5
- package/.agents/scripts/lib/story-plan.js +3 -0
- package/.agents/scripts/lib/wave-runner/tick.js +10 -2
- package/.agents/scripts/lifecycle-emit.js +39 -8
- package/.agents/scripts/providers/github/issues.js +12 -1
- package/.agents/scripts/resolve-doc-tiers.js +83 -0
- package/.agents/scripts/retro-run.js +51 -0
- package/.agents/scripts/signals-view.js +1 -1
- package/.agents/scripts/single-story-close.js +20 -1
- package/.agents/scripts/standalone-feedback-rollup.js +188 -0
- package/.agents/scripts/story-close.js +48 -0
- package/.agents/scripts/story-plan.js +51 -12
- package/.agents/scripts/validate-docs-freshness.js +69 -15
- package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
- package/.agents/skills/core/scope-triage/SKILL.md +61 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/workflows/audit-documentation.md +82 -2
- package/.agents/workflows/helpers/code-review.md +193 -44
- package/.agents/workflows/helpers/deliver-epic.md +128 -39
- package/.agents/workflows/helpers/deliver-stories.md +26 -0
- package/.agents/workflows/helpers/epic-audit.md +116 -283
- package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
- package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
- package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
- package/.agents/workflows/helpers/plan-epic.md +141 -105
- package/.agents/workflows/helpers/plan-story.md +32 -0
- package/.agents/workflows/helpers/single-story-deliver.md +43 -0
- package/.agents/workflows/loops/nightly-audit.md +9 -7
- package/docs/CHANGELOG.md +29 -0
- package/lib/cli/doctor.js +44 -0
- package/package.json +4 -3
- package/.agents/scripts/epic-plan-spec-validate.js +0 -111
- package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
- package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* epic-plan-spec-validate.js — Phase 7.5 Tech Spec post-authoring gate CLI.
|
|
5
|
-
*
|
|
6
|
-
* `/plan` Phase 7 authors the Tech Spec; Phase 8.3 (Holistic
|
|
7
|
-
* Consolidation) reconciles the draft ticket array against the Tech Spec's
|
|
8
|
-
* `## Delivery Slicing` section, which the decompose-author skill uses as the
|
|
9
|
-
* capability-boundary anchor. When that section is absent the consolidation
|
|
10
|
-
* pass runs against a void and produces groupings that reflect technical
|
|
11
|
-
* shape rather than capability boundaries.
|
|
12
|
-
*
|
|
13
|
-
* This CLI is the hard gate between Phase 7 and Phase 8: it reads the authored
|
|
14
|
-
* `techspec.md`, runs {@link ./lib/orchestration/spec-section-validator.js#validateSpecSections},
|
|
15
|
-
* and exits non-zero when the required section is missing so decomposition
|
|
16
|
-
* cannot proceed against an un-anchored spec. It is the Phase 8-side
|
|
17
|
-
* counterpart to the Phase 6 Epic Clarity Gate (`epic-plan-clarity.js`) —
|
|
18
|
-
* same detect-then-prompt pattern, one phase later.
|
|
19
|
-
*
|
|
20
|
-
* Usage:
|
|
21
|
-
* epic-plan-spec-validate.js --techspec <path> [--json]
|
|
22
|
-
*
|
|
23
|
-
* Exit codes:
|
|
24
|
-
* 0 — every required section present.
|
|
25
|
-
* 1 — at least one required section missing, or a fatal error (bad path,
|
|
26
|
-
* unreadable file). The failure message names the missing section(s)
|
|
27
|
-
* and tells the operator how to recover.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { readFile } from 'node:fs/promises';
|
|
31
|
-
import { parseArgs } from 'node:util';
|
|
32
|
-
import { runAsCli } from './lib/cli-utils.js';
|
|
33
|
-
import { Logger } from './lib/Logger.js';
|
|
34
|
-
import { validateSpecSections } from './lib/orchestration/spec-section-validator.js';
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Build the operator-facing failure message for a missing-section result.
|
|
38
|
-
* Names each missing section and tells the operator whether to re-author the
|
|
39
|
-
* spec or add the section by hand before continuing to Phase 8.
|
|
40
|
-
*
|
|
41
|
-
* @param {{ techspecPath: string, missing: string[] }} args
|
|
42
|
-
* @returns {string}
|
|
43
|
-
*/
|
|
44
|
-
export function formatMissingSectionMessage({ techspecPath, missing }) {
|
|
45
|
-
const list = missing.map((name) => `## ${name}`).join(', ');
|
|
46
|
-
return [
|
|
47
|
-
`[epic-plan-spec-validate] Tech Spec is missing required section(s): ${list}`,
|
|
48
|
-
` Spec file: ${techspecPath}`,
|
|
49
|
-
'',
|
|
50
|
-
` Phase 8 (decomposition) reconciles the draft ticket array against the`,
|
|
51
|
-
` Tech Spec's "## Delivery Slicing" section — without it, the Phase 8.3`,
|
|
52
|
-
` consolidation pass has no capability-boundary anchor and groups by`,
|
|
53
|
-
` technical shape instead.`,
|
|
54
|
-
'',
|
|
55
|
-
' To continue, do ONE of the following before re-running Phase 8:',
|
|
56
|
-
` 1. Re-author the Tech Spec (re-run the Phase 7 spec-author step) so it`,
|
|
57
|
-
` emits a "## Delivery Slicing" section, OR`,
|
|
58
|
-
` 2. Add a "## Delivery Slicing" section to the Tech Spec by hand,`,
|
|
59
|
-
` describing the capability boundaries the work should be sliced along.`,
|
|
60
|
-
].join('\n');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Validate an authored Tech Spec file for the required post-authoring
|
|
65
|
-
* sections. Pure-ish wrapper around `validateSpecSections` that owns the file
|
|
66
|
-
* read so the CLI `main` stays a thin arg-parse shell.
|
|
67
|
-
*
|
|
68
|
-
* @param {{ techspecPath: string }} args
|
|
69
|
-
* @returns {Promise<{ ok: boolean, missing: string[], present: string[] }>}
|
|
70
|
-
*/
|
|
71
|
-
export async function validateSpecFile({ techspecPath }) {
|
|
72
|
-
const body = await readFile(techspecPath, 'utf8');
|
|
73
|
-
return validateSpecSections({ body });
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/* node:coverage ignore next */
|
|
77
|
-
async function main() {
|
|
78
|
-
const { values } = parseArgs({
|
|
79
|
-
options: {
|
|
80
|
-
techspec: { type: 'string' },
|
|
81
|
-
json: { type: 'boolean', default: false },
|
|
82
|
-
},
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
if (!values.techspec) {
|
|
86
|
-
throw new Error(
|
|
87
|
-
'Usage: epic-plan-spec-validate.js --techspec <path> [--json]',
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const techspecPath = values.techspec;
|
|
92
|
-
const result = await validateSpecFile({ techspecPath });
|
|
93
|
-
|
|
94
|
-
if (values.json) {
|
|
95
|
-
process.stdout.write(`${JSON.stringify({ techspecPath, ...result })}\n`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (!result.ok) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
formatMissingSectionMessage({ techspecPath, missing: result.missing }),
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
Logger.info(
|
|
105
|
-
`[epic-plan-spec-validate] Tech Spec section gate passed: ${result.present
|
|
106
|
-
.map((name) => `## ${name}`)
|
|
107
|
-
.join(', ')} present.`,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
runAsCli(import.meta.url, main, { source: 'epic-plan-spec-validate' });
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* code-review-graduator.js — Auto-graduate non-blocking code-review
|
|
3
|
-
* findings from the Epic's `code-review` structured comment into routed
|
|
4
|
-
* GitHub follow-up issues.
|
|
5
|
-
*
|
|
6
|
-
* Story #2555 / Epic #2547. As of Story #3845 / Epic #3823 the spawn
|
|
7
|
-
* helper, the path/idempotency probes, the `gh issue create` filer, the
|
|
8
|
-
* toggle reader, and the route → probe → file walk all live in the
|
|
9
|
-
* shared [`graduator-core.js`](./graduator-core.js). This module is the
|
|
10
|
-
* thin code-review-specific shell: it owns the code-review finding parser
|
|
11
|
-
* (the 🟢→low severity mapping, no lens), the code-review label / title /
|
|
12
|
-
* body shape, and the code-review idempotency marker. Behaviour is
|
|
13
|
-
* identical to the pre-consolidation graduator.
|
|
14
|
-
*
|
|
15
|
-
* - Read the `code-review` structured comment off the Epic ticket via
|
|
16
|
-
* the injected provider (findStructuredComment surface).
|
|
17
|
-
* - For each non-blocking finding (severity high/medium/low — i.e.
|
|
18
|
-
* anything that is NOT a 🔴 Critical Blocker), check that the cited
|
|
19
|
-
* file still exists in the merged tree (`git cat-file -e <ref>:<path>`)
|
|
20
|
-
* via the injected spawn seam.
|
|
21
|
-
* - Route by source classification (framework vs consumer) using
|
|
22
|
-
* `classifyPathSource` (S1 helper). When the routed repo differs
|
|
23
|
-
* from the current repo, record under `skipped: 'cross-repo-deferred'`
|
|
24
|
-
* and log the would-be `gh issue create` invocation — do NOT actually
|
|
25
|
-
* shell out against a different repo.
|
|
26
|
-
* - File a follow-up issue with `gh issue create --repo <routed-repo>`
|
|
27
|
-
* carrying a `code-review::<severity>` label plus the matching
|
|
28
|
-
* `meta::<framework-gap|consumer-improvement>` label.
|
|
29
|
-
* - Embed an idempotency marker in each body:
|
|
30
|
-
* <!-- code-review-followup: epic-<id>-finding-<idx> -->
|
|
31
|
-
* Before filing, probe via `gh search issues "<marker>" --repo …`
|
|
32
|
-
* and skip findings whose marker is already present in any issue.
|
|
33
|
-
* - Short-circuit when `config.delivery.feedbackLoop.codeReviewAutoFile`
|
|
34
|
-
* is `false` — return `{ filed: [], skipped: [{reason:
|
|
35
|
-
* 'toggle-disabled'}], errors: [] }`.
|
|
36
|
-
* - NEVER throw. Every failure path (missing comment, parse failure,
|
|
37
|
-
* gh/git spawn error, non-zero exit) is captured in `errors[]`.
|
|
38
|
-
*
|
|
39
|
-
* Tests inject `provider`, `classifier`, and `spawnImpl` to drive every
|
|
40
|
-
* branch deterministically.
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
|
-
import {
|
|
44
|
-
graduate,
|
|
45
|
-
makeIsAutoFileEnabled,
|
|
46
|
-
probePathExists,
|
|
47
|
-
} from './graduator-core.js';
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Resolve the toggle from the resolved agentrc config. Defaults to `true`
|
|
51
|
-
* — the feature is opt-out, not opt-in.
|
|
52
|
-
*
|
|
53
|
-
* @param {object|undefined|null} config
|
|
54
|
-
* @returns {boolean}
|
|
55
|
-
*/
|
|
56
|
-
export const isAutoFileEnabled = makeIsAutoFileEnabled('codeReviewAutoFile');
|
|
57
|
-
|
|
58
|
-
// Re-export the shared path probe so existing importers keep working.
|
|
59
|
-
export { probePathExists };
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Severity → label mapping. Only non-blocking severities have a route;
|
|
63
|
-
* 🔴 Critical Blocker is explicitly filtered out upstream.
|
|
64
|
-
*/
|
|
65
|
-
const SEVERITY_LABEL = Object.freeze({
|
|
66
|
-
high: 'code-review::high',
|
|
67
|
-
medium: 'code-review::medium',
|
|
68
|
-
low: 'code-review::low',
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Map a classification source to its meta routing label.
|
|
73
|
-
*
|
|
74
|
-
* @param {string} source
|
|
75
|
-
* @returns {string}
|
|
76
|
-
*/
|
|
77
|
-
function metaSourceLabel(source) {
|
|
78
|
-
return source === 'framework'
|
|
79
|
-
? 'meta::framework-gap'
|
|
80
|
-
: 'meta::consumer-improvement';
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Compile a marker for a given epicId / finding index. The marker is an
|
|
85
|
-
* HTML comment so it survives GitHub markdown rendering without leaking
|
|
86
|
-
* into the visible body — but it's still indexable via `gh search`.
|
|
87
|
-
*
|
|
88
|
-
* @param {number} epicId
|
|
89
|
-
* @param {number} index — zero-based finding ordinal within the Epic.
|
|
90
|
-
* @returns {string}
|
|
91
|
-
*/
|
|
92
|
-
export function buildIdempotencyMarker(epicId, index) {
|
|
93
|
-
return `<!-- code-review-followup: epic-${epicId}-finding-${index} -->`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Parse the rendered code-review markdown into a list of findings. Each
|
|
98
|
-
* finding has `{ severity, path, summary, index }`. Pure. Exported so
|
|
99
|
-
* the parser can be unit-tested in isolation.
|
|
100
|
-
*
|
|
101
|
-
* The structured `code-review` comment emits findings as bullet lines under the
|
|
102
|
-
* "🚨 Critical Findings" and "🟡 Warnings" sections. Each line begins
|
|
103
|
-
* with a severity emoji and embeds the cited path inside backticks. We
|
|
104
|
-
* filter 🔴 (Critical Blocker — blocking) out; 🟠/🟡/🟢 are non-blocking
|
|
105
|
-
* and graduate to follow-up issues.
|
|
106
|
-
*
|
|
107
|
-
* @param {string} body
|
|
108
|
-
* @returns {Array<{ severity: 'high'|'medium'|'low', path: string, summary: string, index: number }>}
|
|
109
|
-
*/
|
|
110
|
-
export function parseFindings(body) {
|
|
111
|
-
if (typeof body !== 'string' || body.length === 0) return [];
|
|
112
|
-
const findings = [];
|
|
113
|
-
const lines = body.split(/\r?\n/);
|
|
114
|
-
let idx = 0;
|
|
115
|
-
for (const line of lines) {
|
|
116
|
-
const trimmed = line.trim();
|
|
117
|
-
if (trimmed.length === 0) continue;
|
|
118
|
-
let severity = null;
|
|
119
|
-
if (trimmed.startsWith('🟠')) severity = 'high';
|
|
120
|
-
else if (trimmed.startsWith('🟡')) severity = 'medium';
|
|
121
|
-
else if (trimmed.startsWith('🟢')) severity = 'low';
|
|
122
|
-
else continue;
|
|
123
|
-
// Path is the first backticked token on the line.
|
|
124
|
-
const pathMatch = trimmed.match(/`([^`]+)`/);
|
|
125
|
-
if (!pathMatch) continue;
|
|
126
|
-
const path = pathMatch[1];
|
|
127
|
-
// Summary is the line itself, stripped of the leading emoji bullet.
|
|
128
|
-
findings.push({
|
|
129
|
-
severity,
|
|
130
|
-
path,
|
|
131
|
-
summary: trimmed,
|
|
132
|
-
index: idx,
|
|
133
|
-
});
|
|
134
|
-
idx += 1;
|
|
135
|
-
}
|
|
136
|
-
return findings;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Auto-graduate non-blocking code-review findings into routed follow-up
|
|
141
|
-
* issues. Never throws. Thin wrapper around the shared `graduate()` walk
|
|
142
|
-
* with the code-review-specific behaviour bundle.
|
|
143
|
-
*
|
|
144
|
-
* @param {object} opts
|
|
145
|
-
* @param {number} opts.epicId
|
|
146
|
-
* @param {object} opts.provider — ticketing provider exposing
|
|
147
|
-
* `getTicketComments(ticketId)`.
|
|
148
|
-
* @param {object} [opts.config] — resolved agentrc.
|
|
149
|
-
* @param {{owner: string, repo: string}} opts.currentRepo — the repo the
|
|
150
|
-
* listener is running inside; used for the cross-repo guard.
|
|
151
|
-
* @param {{owner: string, repo: string}} [opts.frameworkRepo] — where
|
|
152
|
-
* framework-tagged findings get routed. Defaults to
|
|
153
|
-
* `currentRepo` when this is the framework repo, otherwise typically
|
|
154
|
-
* `{ owner: 'dsj1984', repo: 'mandrel' }`.
|
|
155
|
-
* @param {string} [opts.gitRef='HEAD'] — ref against which to probe path
|
|
156
|
-
* existence.
|
|
157
|
-
* @param {Function} [opts.classifier=classifyPathSource] — S1 helper.
|
|
158
|
-
* @param {string} [opts.ghPath='gh']
|
|
159
|
-
* @param {Function} [opts.spawnImpl]
|
|
160
|
-
* @param {string} [opts.cwd]
|
|
161
|
-
* @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
|
|
162
|
-
* @returns {Promise<{
|
|
163
|
-
* filed: Array<{ index: number, severity: string, path: string, source: string, repo: string, url: string|null }>,
|
|
164
|
-
* skipped: Array<{ index?: number, reason: string, path?: string, severity?: string }>,
|
|
165
|
-
* errors: string[],
|
|
166
|
-
* }>}
|
|
167
|
-
*/
|
|
168
|
-
export async function graduateFindings(opts = {}) {
|
|
169
|
-
return graduate({
|
|
170
|
-
...opts,
|
|
171
|
-
spec: {
|
|
172
|
-
fnName: 'graduateFindings',
|
|
173
|
-
isAutoFileEnabled,
|
|
174
|
-
commentMarker: '<!-- structured-comment: code-review -->',
|
|
175
|
-
noCommentReason: 'no-code-review-comment',
|
|
176
|
-
parseFindings,
|
|
177
|
-
buildIdempotencyMarker,
|
|
178
|
-
buildCrossRepoLog: ({ finding, routedRepo, source }) => {
|
|
179
|
-
const metaLabel = metaSourceLabel(source);
|
|
180
|
-
return `[code-review-graduator] cross-repo skip (would file in ${routedRepo.owner}/${routedRepo.repo}): gh issue create --repo ${routedRepo.owner}/${routedRepo.repo} --title "Code review follow-up: ${finding.path}" --label "${metaLabel},${SEVERITY_LABEL[finding.severity]}"`;
|
|
181
|
-
},
|
|
182
|
-
buildFollowUp: ({ finding, source, epicId, idMarker }) => {
|
|
183
|
-
const metaLabel = metaSourceLabel(source);
|
|
184
|
-
const title = `Code review follow-up: ${finding.path}`;
|
|
185
|
-
const body = [
|
|
186
|
-
idMarker,
|
|
187
|
-
'',
|
|
188
|
-
`Auto-filed from the Epic #${epicId} code-review pass.`,
|
|
189
|
-
'',
|
|
190
|
-
`**Severity**: ${finding.severity}`,
|
|
191
|
-
`**Source**: ${source}`,
|
|
192
|
-
`**Path**: \`${finding.path}\``,
|
|
193
|
-
'',
|
|
194
|
-
'### Finding',
|
|
195
|
-
'',
|
|
196
|
-
finding.summary,
|
|
197
|
-
'',
|
|
198
|
-
`_See Epic #${epicId} for the full code-review report._`,
|
|
199
|
-
].join('\n');
|
|
200
|
-
const labels = [metaLabel, SEVERITY_LABEL[finding.severity]];
|
|
201
|
-
return { title, body, labels };
|
|
202
|
-
},
|
|
203
|
-
},
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export default graduateFindings;
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* phases/prompts.js — Canonical Tech Spec / Acceptance Spec system prompts for
|
|
3
|
-
* the spec phase of `/plan`.
|
|
4
|
-
*
|
|
5
|
-
* These ride along on the `--emit-context` envelope as a backstop. The
|
|
6
|
-
* `epic-plan-spec-author` Skill
|
|
7
|
-
* (`.agents/skills/core/epic-plan-spec-author/SKILL.md`) embeds the
|
|
8
|
-
* authoritative copies of these strings — keep the two surfaces in sync when
|
|
9
|
-
* either is edited.
|
|
10
|
-
*
|
|
11
|
-
* Story #4314: the PRD artifact class is retired. The Epic body (which now
|
|
12
|
-
* carries its `## User Stories` section inline) is the sole authoring input;
|
|
13
|
-
* both prompts consume the Epic body directly rather than a paraphrased PRD.
|
|
14
|
-
*
|
|
15
|
-
* Story #4324: the Tech Spec and Acceptance Spec are no longer separate
|
|
16
|
-
* `context::*` tickets — the authored content lands as managed sections of
|
|
17
|
-
* the same Epic body (`## Delivery Slicing`-led Tech Spec sections, and the
|
|
18
|
-
* `## Acceptance Table` AC-ID table). Content semantics are unchanged; only
|
|
19
|
-
* WHERE the output lives moved.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
export const TECH_SPEC_SYSTEM_PROMPT = `You are an expert Engineering Architect.
|
|
23
|
-
Your job is to convert an Epic into a Technical Specification for implementation.
|
|
24
|
-
|
|
25
|
-
The Tech Spec should outline:
|
|
26
|
-
1. Delivery Slicing — propose how the Epic's enumerated capabilities cluster into shippable Stories. This count is a CEILING, not a target: the Phase 8 consolidation pass may merge below your proposed count when slices form dependent single-consumer chains, but never splits above it. Do NOT coarsen the Epic enumeration to produce this; the grouping recommendation is the granularity lever.
|
|
27
|
-
2. Architecture & Design
|
|
28
|
-
3. Data Models (if any)
|
|
29
|
-
4. API Changes (if any)
|
|
30
|
-
5. Core Components
|
|
31
|
-
6. Security & Privacy Considerations
|
|
32
|
-
|
|
33
|
-
CRITICAL REQUIREMENTS:
|
|
34
|
-
- Respond ONLY with valid Markdown.
|
|
35
|
-
- Do not use top-level <h1> (# ) tags. Open the document with the \`## Delivery Slicing\` section — it is the primary input to Phase 8 consolidation, so author it first and hang the rest of the spec off it.
|
|
36
|
-
- Do NOT restate the Epic's Context, Goal, or Scope — your output lands as sections of the same Epic body, which travels into every downstream story agent's prompt, so any restatement is pure duplication and a drift risk. If a brief technical orientation is genuinely useful, add an optional \`## Technical Overview\` of no more than 2–3 sentences that names the *technical approach* only (which subsystems are touched and reused); never re-narrate the problem statement, goals, or scope.
|
|
37
|
-
- Format architectural decisions clearly with bullet points.
|
|
38
|
-
- Author the \`## Delivery Slicing\` section as a markdown table with columns \`Slice | What ships | Independent?\`, using noun-phrase slice names (e.g. "Foundation", "Transport seam", "Send helper") that map onto Feature titles. "Independent?" answers: can this slice ship to production and provide value without the next slice landing? A slice you mark "Independent? No" MUST carry a one-line justification (parallelism, risk isolation, or delivery-envelope pressure); an unjustified dependent single-consumer slice folds into its consumer by default rather than shipping as its own Story.`;
|
|
39
|
-
|
|
40
|
-
export const ACCEPTANCE_SPEC_SYSTEM_PROMPT = `You are an expert Acceptance Engineer.
|
|
41
|
-
Your job is to convert an Epic and a Tech Spec into a structured Acceptance Specification that drives features-first BDD authoring.
|
|
42
|
-
|
|
43
|
-
The Acceptance Spec should outline:
|
|
44
|
-
1. Acceptance Table — one row per user-visible outcome, expressed as a Markdown table with columns: AC ID | Outcome | Feature File | Scenario | Disposition
|
|
45
|
-
2. Stable AC IDs — assign AC-1, AC-2, ... in document order; reuse the same ID across re-plans when an Outcome is materially unchanged so scenario tags (@ac-N) stay aligned
|
|
46
|
-
3. Disposition — tag each row with one of: new | updated | unchanged
|
|
47
|
-
|
|
48
|
-
The Epic body's \`## Acceptance Criteria\` bullets are the single source of truth for what the spec verifies. Your table does not re-invent criteria — it anchors each one to a specific Epic AC bullet.
|
|
49
|
-
|
|
50
|
-
CRITICAL REQUIREMENTS:
|
|
51
|
-
- Respond ONLY with valid Markdown.
|
|
52
|
-
- Do not use top-level <h1> (# ) tags. Start with ## Acceptance Table — the table lands as a section of the Epic body, so it must NOT reuse the Epic's own ## Acceptance Criteria heading.
|
|
53
|
-
- Every AC row MUST have a stable AC ID of the form AC-<n> (AC-1, AC-2, ...) — do not reorder IDs across re-plans; new ACs get fresh sequential IDs.
|
|
54
|
-
- Every AC row MUST carry a Disposition value from the enum: new | updated | unchanged. (At Epic close, the acceptance reconciler overwrites Disposition with the verification outcome — satisfied | pending | missing — inside this section only; on re-plan, reset each row to the authoring enum.)
|
|
55
|
-
- Each Outcome MUST be a **terse restatement keyed to a specific Epic \`## Acceptance Criteria\` bullet** — lead the Outcome with the bullet's anchor (its quoted lead phrase or an explicit "Epic AC N" index) and keep the rest to a single user-visible behaviour. Do NOT re-elaborate the Epic bullet in independent words: a free-standing Outcome that paraphrases the criterion without naming the bullet it verifies is forbidden, because it drifts from the Epic silently. No DB assertions, no HTTP status codes, no internal implementation details.
|
|
56
|
-
- Where one Epic AC bullet genuinely expands into several user-visible outcomes, emit one row per outcome and declare the split on each — e.g. lead with "splits Epic AC 3" — so the fan-out is explicit rather than hidden.
|
|
57
|
-
- Anchor coverage MUST be complete and auditable: every Epic AC bullet MUST be covered by at least one row, and every row MUST anchor to an Epic AC bullet. Flag divergence in the authored spec instead of dropping it — if an Epic AC bullet has no corresponding row, or a row has no Epic anchor, call it out explicitly (a note beneath the table) rather than silently omitting the bullet or emitting an unanchored row.
|
|
58
|
-
- Cite proposed feature file paths under tests/features/** so Phase 8 can scaffold matching scenarios.`;
|
|
@@ -1,292 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Hotspot detector — pure module (Epic #1721 / Story #1769 / Task #1776).
|
|
3
|
-
*
|
|
4
|
-
* Walks every Story directory under `temp/epic-<eid>/`, aggregates edit
|
|
5
|
-
* counts per `details.targetHash` for file-mutating tools across Stories,
|
|
6
|
-
* and emits one `kind: 'hotspot'` SignalEvent per hash whose total edit
|
|
7
|
-
* count exceeds `p95 * multiplier`. Pure: takes config-shaped args in,
|
|
8
|
-
* returns events out. Caller persists via `appendEpicSignal` (the signal
|
|
9
|
-
* is Epic-scope; there is no single owning Story).
|
|
10
|
-
*
|
|
11
|
-
* ## Counting rule
|
|
12
|
-
*
|
|
13
|
-
* Only file-mutating tools are counted:
|
|
14
|
-
* - `Edit`
|
|
15
|
-
* - `Write`
|
|
16
|
-
* - `MultiEdit`
|
|
17
|
-
* - `NotebookEdit`
|
|
18
|
-
*
|
|
19
|
-
* Every other tool (Read, Bash, Grep, Glob, …) is ignored. Trace records
|
|
20
|
-
* without a `details.targetHash` are also skipped — without a stable key
|
|
21
|
-
* we cannot group repeats.
|
|
22
|
-
*
|
|
23
|
-
* ## Cross-Story-only percentile pool
|
|
24
|
-
*
|
|
25
|
-
* Hashes that appear in **fewer than 2 Stories** are excluded from the
|
|
26
|
-
* p95 calculation. This prevents a single large Story from manufacturing
|
|
27
|
-
* its own hotspot when no other Story touches the same file. The pool
|
|
28
|
-
* for the percentile is therefore the set of `totalEdits` values for
|
|
29
|
-
* hashes seen in ≥ 2 Stories. Hashes outside that pool also do not
|
|
30
|
-
* emit a signal — by construction they are not cross-Story.
|
|
31
|
-
*
|
|
32
|
-
* ## p95 algorithm
|
|
33
|
-
*
|
|
34
|
-
* Nearest-rank method (no interpolation): for a sorted array of length
|
|
35
|
-
* `n`, the p95 index is `ceil(0.95 * n) - 1` (0-based). For
|
|
36
|
-
* `[1,2,3,4,5,6,7,8,9,10]` the index is `ceil(9.5) - 1 = 9` → value `10`.
|
|
37
|
-
* For a single-element array `[10]` the index is `ceil(0.95) - 1 = 0` →
|
|
38
|
-
* value `10`. Nearest-rank keeps fixture-driven tests deterministic
|
|
39
|
-
* across Node versions and avoids the floating-point ambiguity of
|
|
40
|
-
* linear-interpolation variants.
|
|
41
|
-
*
|
|
42
|
-
* ## Privacy contract
|
|
43
|
-
*
|
|
44
|
-
* Trace records key off `details.targetHash` (a sha256 of the file path,
|
|
45
|
-
* see `lib/observability/tool-trace-hook.js`). The detector groups by
|
|
46
|
-
* the hash, never the raw path, so the privacy boundary established by
|
|
47
|
-
* the hook is preserved end-to-end.
|
|
48
|
-
*
|
|
49
|
-
* ## Threshold semantics
|
|
50
|
-
*
|
|
51
|
-
* `totalEdits > p95 * multiplier` (strictly greater than). A hash with
|
|
52
|
-
* exactly `p95 * multiplier` edits does NOT emit; only counts past the
|
|
53
|
-
* threshold trip the detector. The multiplier comes from
|
|
54
|
-
* `delivery.signals.hotspot.p95Multiplier` (default 1.25) — pass it in
|
|
55
|
-
* via `args.multiplier`. The detector itself does not import the config
|
|
56
|
-
* resolver.
|
|
57
|
-
*
|
|
58
|
-
* ## Robustness
|
|
59
|
-
*
|
|
60
|
-
* - Missing `temp/epic-<eid>/` → returns `[]`. Never throws.
|
|
61
|
-
* - Story directories with no `traces.ndjson` → contribute zero edits.
|
|
62
|
-
* - Malformed JSON lines → silently skipped (consistent with
|
|
63
|
-
* `lib/signals/read.js` and `detectors/rework.js`).
|
|
64
|
-
* - Non-trace records → ignored (the file may legitimately interleave
|
|
65
|
-
* other kinds in future).
|
|
66
|
-
*
|
|
67
|
-
* @module lib/signals/detectors/hotspot
|
|
68
|
-
*/
|
|
69
|
-
|
|
70
|
-
import { createReadStream } from 'node:fs';
|
|
71
|
-
import fs from 'node:fs/promises';
|
|
72
|
-
import path from 'node:path';
|
|
73
|
-
import { createInterface } from 'node:readline';
|
|
74
|
-
import { epicTempDir } from '../../config/temp-paths.js';
|
|
75
|
-
import { parseStoryBranch } from '../../git-utils.js';
|
|
76
|
-
import { extractTool, validateDetectorArgs } from './common.js';
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Tools that mutate files. Only these contribute to the per-target edit
|
|
80
|
-
* count. Anything outside this set is ignored.
|
|
81
|
-
*
|
|
82
|
-
* @type {ReadonlySet<string>}
|
|
83
|
-
*/
|
|
84
|
-
const FILE_MUTATING_TOOLS = Object.freeze(
|
|
85
|
-
new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']),
|
|
86
|
-
);
|
|
87
|
-
|
|
88
|
-
function isPositiveNumber(v) {
|
|
89
|
-
return typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Stream a single `traces.ndjson` line-by-line and emit per-targetHash
|
|
94
|
-
* edit counts as a `Map<targetHash, count>`. Missing file → empty map.
|
|
95
|
-
*
|
|
96
|
-
* @param {string} tracesPath
|
|
97
|
-
* @returns {Promise<Map<string, number>>}
|
|
98
|
-
*/
|
|
99
|
-
async function tallyEditsByTarget(tracesPath) {
|
|
100
|
-
const counts = new Map();
|
|
101
|
-
|
|
102
|
-
// Existence check before opening the stream — `createReadStream`
|
|
103
|
-
// defers ENOENT until the first read, which leaves the iterator in a
|
|
104
|
-
// bad state on some Node versions.
|
|
105
|
-
try {
|
|
106
|
-
await fs.access(tracesPath);
|
|
107
|
-
} catch {
|
|
108
|
-
return counts;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const stream = createReadStream(tracesPath, { encoding: 'utf8' });
|
|
112
|
-
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
113
|
-
|
|
114
|
-
try {
|
|
115
|
-
for await (const rawLine of rl) {
|
|
116
|
-
if (rawLine.length === 0) continue;
|
|
117
|
-
let parsed;
|
|
118
|
-
try {
|
|
119
|
-
parsed = JSON.parse(rawLine);
|
|
120
|
-
} catch {
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (parsed == null || typeof parsed !== 'object') continue;
|
|
124
|
-
if (parsed.kind !== 'trace') continue;
|
|
125
|
-
|
|
126
|
-
const tool = extractTool(parsed);
|
|
127
|
-
if (tool == null || !FILE_MUTATING_TOOLS.has(tool)) continue;
|
|
128
|
-
|
|
129
|
-
const hash = parsed.details?.targetHash;
|
|
130
|
-
if (typeof hash !== 'string' || hash.length === 0) continue;
|
|
131
|
-
|
|
132
|
-
counts.set(hash, (counts.get(hash) ?? 0) + 1);
|
|
133
|
-
}
|
|
134
|
-
} finally {
|
|
135
|
-
rl.close();
|
|
136
|
-
if (!stream.destroyed) stream.destroy();
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
return counts;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Enumerate `story-<id>` subdirectories of `epicDir`. Returns an empty
|
|
144
|
-
* array when `epicDir` does not exist. The ordering is by Story ID
|
|
145
|
-
* ascending so `storiesAffected` and any future audit output are stable.
|
|
146
|
-
*
|
|
147
|
-
* @param {string} epicDir
|
|
148
|
-
* @returns {Promise<string[]>} absolute paths to each story directory
|
|
149
|
-
*/
|
|
150
|
-
async function listStoryDirs(epicDir) {
|
|
151
|
-
let entries;
|
|
152
|
-
try {
|
|
153
|
-
entries = await fs.readdir(epicDir, { withFileTypes: true });
|
|
154
|
-
} catch {
|
|
155
|
-
return [];
|
|
156
|
-
}
|
|
157
|
-
const stories = [];
|
|
158
|
-
for (const ent of entries) {
|
|
159
|
-
if (!ent.isDirectory()) continue;
|
|
160
|
-
const id = parseStoryBranch(ent.name);
|
|
161
|
-
if (id == null) continue;
|
|
162
|
-
stories.push({ id, dir: path.join(epicDir, ent.name) });
|
|
163
|
-
}
|
|
164
|
-
stories.sort((a, b) => a.id - b.id);
|
|
165
|
-
return stories.map((s) => s.dir);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* Nearest-rank p95 of a numeric array. Returns `0` for an empty input.
|
|
170
|
-
*
|
|
171
|
-
* The pool is sorted ascending in-place on a defensive copy and the
|
|
172
|
-
* value at index `ceil(0.95 * n) - 1` (0-based) is returned. No
|
|
173
|
-
* interpolation — keeps fixture tests deterministic.
|
|
174
|
-
*
|
|
175
|
-
* @param {readonly number[]} values
|
|
176
|
-
* @returns {number}
|
|
177
|
-
*/
|
|
178
|
-
export function nearestRankP95(values) {
|
|
179
|
-
if (!Array.isArray(values) || values.length === 0) return 0;
|
|
180
|
-
const sorted = [...values].sort((a, b) => a - b);
|
|
181
|
-
const idx = Math.ceil(0.95 * sorted.length) - 1;
|
|
182
|
-
return sorted[Math.max(0, Math.min(sorted.length - 1, idx))];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Detect Epic-scope hotspots. Returns one `kind: 'hotspot'` SignalEvent
|
|
187
|
-
* per `targetHash` whose cross-Story `totalEdits` strictly exceeds
|
|
188
|
-
* `p95(pool) * multiplier`, where `pool` is the set of `totalEdits`
|
|
189
|
-
* values for hashes appearing in ≥ 2 Stories.
|
|
190
|
-
*
|
|
191
|
-
* Pure — emission to disk is the caller's responsibility (use
|
|
192
|
-
* `appendEpicSignal` since the signal is Epic-scope).
|
|
193
|
-
*
|
|
194
|
-
* @param {object} args
|
|
195
|
-
* @param {number} args.epicId — positive integer Epic ID.
|
|
196
|
-
* @param {number} args.multiplier — positive number; p95 multiplier.
|
|
197
|
-
* @param {string} [args.tempRoot] — override `temp/` root (mostly for
|
|
198
|
-
* tests that point at a synthetic fixture tree).
|
|
199
|
-
* @param {() => string} [args.nowFn] — optional clock seam returning the
|
|
200
|
-
* ISO-8601 `ts` stamped onto every emitted SignalEvent. Defaults to
|
|
201
|
-
* `() => new Date().toISOString()`. Inject a fixed-return function in
|
|
202
|
-
* tests to make the emitted `ts` deterministic. MUST, when provided, be
|
|
203
|
-
* a function.
|
|
204
|
-
* @returns {Promise<object[]>} array of hotspot SignalEvents conforming
|
|
205
|
-
* to `.agents/schemas/signal-event.schema.json`'s envelope. Emitted
|
|
206
|
-
* `details` payload: `{ targetHash, totalEdits, storiesAffected,
|
|
207
|
-
* p95Threshold, multiplier }`.
|
|
208
|
-
*/
|
|
209
|
-
export async function detectHotspot(args) {
|
|
210
|
-
// Hotspot shares only the args-shape, nowFn, and epicId guards with the
|
|
211
|
-
// Story-scoped detectors — it has no tracesPath/storyId/taskId/threshold.
|
|
212
|
-
// Gate those three off and validate multiplier/tempRoot (hotspot-specific)
|
|
213
|
-
// inline below.
|
|
214
|
-
const { epicId, nowFn } = validateDetectorArgs(args, {
|
|
215
|
-
fnName: 'detectHotspot',
|
|
216
|
-
requireTracesPath: false,
|
|
217
|
-
requireStoryId: false,
|
|
218
|
-
requireThreshold: false,
|
|
219
|
-
});
|
|
220
|
-
const { multiplier, tempRoot } = args;
|
|
221
|
-
|
|
222
|
-
if (!isPositiveNumber(multiplier)) {
|
|
223
|
-
throw new RangeError(
|
|
224
|
-
`detectHotspot: multiplier must be a positive number (got ${multiplier})`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
if (
|
|
228
|
-
tempRoot != null &&
|
|
229
|
-
(typeof tempRoot !== 'string' || tempRoot.length === 0)
|
|
230
|
-
) {
|
|
231
|
-
throw new TypeError(
|
|
232
|
-
`detectHotspot: tempRoot, when provided, must be a non-empty string (got ${tempRoot})`,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Resolve the Epic temp dir. When the caller supplies an explicit
|
|
237
|
-
// `tempRoot`, splice it into the `{ project: { paths: { tempRoot } } }`
|
|
238
|
-
// shape that `epicTempDir` understands without forcing a config load.
|
|
239
|
-
const epicDir = tempRoot
|
|
240
|
-
? epicTempDir(epicId, { project: { paths: { tempRoot } } })
|
|
241
|
-
: epicTempDir(epicId);
|
|
242
|
-
|
|
243
|
-
const storyDirs = await listStoryDirs(epicDir);
|
|
244
|
-
if (storyDirs.length === 0) return [];
|
|
245
|
-
|
|
246
|
-
// Per-Story edit-count maps, then aggregate per-hash totals plus the
|
|
247
|
-
// count of distinct Stories each hash was seen in.
|
|
248
|
-
const totals = new Map(); // hash -> totalEdits across Stories
|
|
249
|
-
const storyHits = new Map(); // hash -> count of distinct Stories
|
|
250
|
-
|
|
251
|
-
for (const storyDir of storyDirs) {
|
|
252
|
-
const tracesPath = path.join(storyDir, 'traces.ndjson');
|
|
253
|
-
const perStory = await tallyEditsByTarget(tracesPath);
|
|
254
|
-
for (const [hash, count] of perStory) {
|
|
255
|
-
totals.set(hash, (totals.get(hash) ?? 0) + count);
|
|
256
|
-
storyHits.set(hash, (storyHits.get(hash) ?? 0) + 1);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Cross-Story-only pool: keep hashes seen in ≥ 2 Stories. Single-Story
|
|
261
|
-
// hashes never qualify as hotspots regardless of their absolute count.
|
|
262
|
-
const crossStoryHashes = [];
|
|
263
|
-
for (const [hash, hits] of storyHits) {
|
|
264
|
-
if (hits >= 2) crossStoryHashes.push(hash);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (crossStoryHashes.length === 0) return [];
|
|
268
|
-
|
|
269
|
-
const pool = crossStoryHashes.map((h) => totals.get(h));
|
|
270
|
-
const p95 = nearestRankP95(pool);
|
|
271
|
-
const threshold = p95 * multiplier;
|
|
272
|
-
|
|
273
|
-
// Sort offenders by hash ascending for stable emission order.
|
|
274
|
-
const offenders = crossStoryHashes
|
|
275
|
-
.filter((h) => totals.get(h) > threshold)
|
|
276
|
-
.sort();
|
|
277
|
-
|
|
278
|
-
const ts = nowFn();
|
|
279
|
-
return offenders.map((targetHash) => ({
|
|
280
|
-
ts,
|
|
281
|
-
kind: 'hotspot',
|
|
282
|
-
source: { tool: 'hotspot-detector' },
|
|
283
|
-
epicId,
|
|
284
|
-
details: {
|
|
285
|
-
targetHash,
|
|
286
|
-
totalEdits: totals.get(targetHash),
|
|
287
|
-
storiesAffected: storyHits.get(targetHash),
|
|
288
|
-
p95Threshold: threshold,
|
|
289
|
-
multiplier,
|
|
290
|
-
},
|
|
291
|
-
}));
|
|
292
|
-
}
|