session-orchestrator 4.1.0 → 5.0.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/skills/session-plan/SKILL.md +1 -1
- package/.agents/skills/session-start/SKILL.md +1 -1
- package/.agents/skills/ux-grill/SKILL.md +22 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +3 -2
- package/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
- package/.codex-plugin/skills/session-start/SKILL.md +1 -1
- package/.codex-plugin/skills/ux-grill/SKILL.md +21 -0
- package/.codex-plugin/skills/ux-grill/agents/openai.yaml +5 -0
- package/.cursor/commands/ux-grill.md +14 -0
- package/.cursor/skills/session-plan/SKILL.md +1 -1
- package/.cursor/skills/session-start/SKILL.md +1 -1
- package/.cursor/skills/ux-grill/SKILL.md +13 -0
- package/.cursor-plugin/plugin.json +1 -1
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +128 -1
- package/README.md +98 -86
- package/agents/dialectic-deriver.md +11 -0
- package/agents/ux-evaluator.md +1 -1
- package/commands/close.md +3 -3
- package/commands/go.md +2 -0
- package/commands/memory-cleanup.md +4 -3
- package/commands/persona-panel.md +1 -1
- package/commands/session.md +3 -2
- package/commands/ux-grill.md +51 -0
- package/docs/README.md +4 -4
- package/docs/USER-GUIDE.md +117 -50
- package/docs/agent-authoring.md +2 -2
- package/docs/baseline.md +55 -1
- package/docs/ci-setup.md +1 -1
- package/docs/codex-setup.md +9 -0
- package/docs/components.md +9 -9
- package/docs/cursor-setup.md +1 -0
- package/docs/events-schema.md +13 -6
- package/docs/github-mirror-protection.md +61 -20
- package/docs/instruction-delivery.md +1 -1
- package/docs/memory-proposal-flow.md +3 -3
- package/docs/migration-v4.md +2 -2
- package/docs/migration-v5.md +62 -0
- package/docs/owner-config-schema.md +74 -90
- package/docs/persona-panel.md +4 -4
- package/docs/pi-setup.md +1 -0
- package/docs/rule-authoring.md +13 -6
- package/docs/scope-collision-guard.md +16 -0
- package/docs/session-config-reference.md +55 -22
- package/docs/session-config-template.md +9 -5
- package/docs/vault-docs-architecture.md +4 -2
- package/hooks/_lib/hook-import-set.json +70 -3
- package/hooks/_lib/lock-bootstrap.mjs +84 -1
- package/hooks/_lib/vcs-create-matcher.mjs +401 -16
- package/hooks/enforce-scope.mjs +201 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +5 -0
- package/hooks/hooks.json +7 -2
- package/hooks/on-session-start.mjs +171 -49
- package/hooks/post-bash-issue-budget-refund.mjs +375 -0
- package/hooks/pre-auq-clarity.mjs +70 -18
- package/hooks/pre-bash-issue-budget.mjs +170 -26
- package/hooks/subagent-telemetry.mjs +106 -20
- package/package.json +5 -4
- package/pi/prompts/ux-grill.md +12 -0
- package/scripts/baseline-archetypes.mjs +28 -0
- package/scripts/ci/assert-vitest-green.mjs +4 -2
- package/scripts/dialectic-deriver.mjs +32 -8
- package/scripts/emit-session.mjs +72 -1
- package/scripts/lib/agent-status.mjs +441 -9
- package/scripts/lib/auq/schema.mjs +10 -3
- package/scripts/lib/auto-dialectic.mjs +0 -68
- package/scripts/lib/baseline-archetypes.mjs +439 -0
- package/scripts/lib/build-live-signals.mjs +5 -6
- package/scripts/lib/ci-status-banner.mjs +29 -6
- package/scripts/lib/claude-md-budget-lint.mjs +52 -2
- package/scripts/lib/config/issue-budget.mjs +68 -8
- package/scripts/lib/config/private-config-dir.mjs +3 -2
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config-schema.mjs +79 -0
- package/scripts/lib/config.mjs +12 -1
- package/scripts/lib/eval/engine.mjs +7 -1
- package/scripts/lib/file-lock.mjs +151 -8
- package/scripts/lib/git-porcelain.mjs +113 -0
- package/scripts/lib/instruction-budget-guard.mjs +415 -47
- package/scripts/lib/io.mjs +29 -4
- package/scripts/lib/issue-budget-reconcile.mjs +392 -0
- package/scripts/lib/issue-budget.mjs +412 -9
- package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
- package/scripts/lib/learnings/sizing-subject.mjs +44 -0
- package/scripts/lib/locks/staging-fence-lock.mjs +19 -38
- package/scripts/lib/locks/state-md-lock.mjs +19 -41
- package/scripts/lib/maintenance-due-banner.mjs +450 -0
- package/scripts/lib/owner-config.example.yaml +29 -46
- package/scripts/lib/owner-yaml.mjs +14 -13
- package/scripts/lib/peer-cards/merger.mjs +143 -0
- package/scripts/lib/pre-dispatch-check.mjs +20 -14
- package/scripts/lib/project-hygiene.mjs +81 -30
- package/scripts/lib/quality-gate.mjs +27 -71
- package/scripts/lib/reconcile/engine.mjs +19 -1
- package/scripts/lib/reconcile/writer.mjs +278 -11
- package/scripts/lib/resource-probe/evaluate.mjs +19 -21
- package/scripts/lib/rules-sync.mjs +34 -4
- package/scripts/lib/scope-echo.mjs +346 -0
- package/scripts/lib/session-close-backfill.mjs +182 -40
- package/scripts/lib/session-end/phase-skip.mjs +85 -86
- package/scripts/lib/session-end/tail-runner.mjs +178 -0
- package/scripts/lib/session-lock.mjs +62 -2
- package/scripts/lib/session-record-repair.mjs +91 -0
- package/scripts/lib/session-schema/constants.mjs +6 -0
- package/scripts/lib/session-schema/filters.mjs +26 -1
- package/scripts/lib/session-schema/validator.mjs +20 -0
- package/scripts/lib/session-shape.mjs +558 -0
- package/scripts/lib/session-start-probes.mjs +429 -56
- package/scripts/lib/session-token-rollup.mjs +95 -10
- package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
- package/scripts/lib/state-md.mjs +1 -0
- package/scripts/lib/subagents-schema.mjs +77 -9
- package/scripts/lib/telemetry/pricing.mjs +197 -0
- package/scripts/lib/telemetry/sync.mjs +50 -1
- package/scripts/lib/test-runner/artifact-paths.mjs +30 -5
- package/scripts/lib/test-runner/issue-reconcile.mjs +45 -8
- package/scripts/lib/tmux-layout/layouts.mjs +62 -4
- package/scripts/lib/ux-grill/collect.mjs +1163 -0
- package/scripts/lib/ux-grill/compare.mjs +285 -0
- package/scripts/lib/ux-grill/manifest.mjs +618 -0
- package/scripts/lib/ux-grill/measures.mjs +431 -0
- package/scripts/lib/ux-grill/paths.mjs +224 -0
- package/scripts/lib/ux-grill/pencil-coverage.mjs +284 -0
- package/scripts/lib/ux-grill/reconcile.mjs +344 -0
- package/scripts/lib/ux-grill/run-record.mjs +316 -0
- package/scripts/lib/ux-grill/schema.mjs +321 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
- package/scripts/lib/validate/check-untracked-test-deps.mjs +33 -19
- package/scripts/lib/validate/check-unwired-features.mjs +56 -27
- package/scripts/lib/vault-mirror/process.mjs +2 -1
- package/scripts/lib/vault-status/board-lock.mjs +18 -0
- package/scripts/lib/vault-status/board-writer.mjs +8 -0
- package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
- package/scripts/lib/wave-resource-gate.mjs +23 -27
- package/scripts/lib/wave-sizing.mjs +10 -3
- package/scripts/materialize-wave-scope.mjs +68 -14
- package/scripts/mcp-server.sh +16 -1
- package/scripts/print-applicable-rules.mjs +7 -6
- package/scripts/print-learnings-index.mjs +3 -2
- package/scripts/release.mjs +7 -2
- package/scripts/session-shape.mjs +266 -0
- package/skills/_shared/config-reading.md +15 -9
- package/skills/_shared/private-capability-context.md +89 -0
- package/skills/bootstrap/SKILL.md +60 -209
- package/skills/bootstrap/_shared-template.md +99 -14
- package/skills/bootstrap/deep-template.md +36 -26
- package/skills/bootstrap/fast-template.md +44 -8
- package/skills/bootstrap/intensity-heuristic.md +10 -4
- package/skills/bootstrap/private-contract.md +119 -0
- package/skills/bootstrap/public-fallback.md +30 -18
- package/skills/bootstrap/references/bootstrap-ecosystem-health-flow.md +48 -0
- package/skills/bootstrap/references/bootstrap-refresh-lock-flow.md +37 -0
- package/skills/bootstrap/references/bootstrap-retroactive-flow.md +108 -0
- package/skills/bootstrap/references/bootstrap-rules-fetch-bridge.md +64 -0
- package/skills/bootstrap/standard-template.md +39 -24
- package/skills/claude-md-drift-check/SKILL.md +9 -2
- package/skills/claude-md-drift-check/checker.mjs +213 -21
- package/skills/discovery/SKILL.md +6 -173
- package/skills/discovery/probes/vault-staleness.mjs +35 -5
- package/skills/discovery/probes-docs.md +8 -4
- package/skills/discovery/probes-supply-chain.md +4 -2
- package/skills/discovery/probes-ui.md +8 -4
- package/skills/discovery/probes-vault.md +12 -4
- package/skills/discovery/references/discovery-interactive-triage.md +139 -0
- package/skills/discovery/references/discovery-triage-state.md +54 -0
- package/skills/docs-orchestrator/audience-mapping.md +1 -1
- package/skills/eval/rubric-v1.md +13 -0
- package/skills/evolve/SKILL.md +2 -458
- package/skills/evolve/references/evolve-analyze-mode.md +360 -0
- package/skills/evolve/references/evolve-dialectic-mode.md +139 -0
- package/skills/gitlab-ops/SKILL.md +3 -3
- package/skills/grill/SKILL.md +1 -1
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/plan/mode-new.md +9 -0
- package/skills/plan/mode-retro.md +4 -3
- package/skills/reconcile/SKILL.md +11 -1
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/drift-operations.md +20 -5
- package/skills/session-end/metrics-collection.md +1 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +27 -67
- package/skills/session-end/phase-3-7a-recommendations.md +2 -2
- package/skills/session-end/references/phase-2-quality-gate.md +3 -3
- package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
- package/skills/session-end/references/phase-5-issue-cleanup.md +32 -1
- package/skills/session-end/session-metrics-write.md +33 -12
- package/skills/session-plan/SKILL.md +46 -180
- package/skills/session-plan/references/session-plan-task-classification.md +152 -0
- package/skills/session-plan/wave-template.md +8 -15
- package/skills/session-start/SKILL.md +41 -7
- package/skills/session-start/phase-2-5-docs-planning.md +1 -1
- package/skills/session-start/phase-8-5-express-path.md +12 -9
- package/skills/session-start/references/operations-contract.md +114 -0
- package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
- package/skills/session-start/references/phase-4-ssot-environment-check.md +42 -24
- package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
- package/skills/session-start/soul.md +2 -2
- package/skills/test-runner/SKILL.md +1 -1
- package/skills/test-runner/rubric-v1.md +2 -2
- package/skills/tmux-layout/SKILL.md +3 -1
- package/skills/ux-grill/SKILL.md +211 -0
- package/skills/ux-grill/rubric-v2.md +201 -0
- package/skills/ux-grill/soul.md +76 -0
- package/skills/wave-executor/SKILL.md +32 -127
- package/skills/wave-executor/circuit-breaker.md +3 -1
- package/skills/wave-executor/references/wave-executor-quality-gate.md +61 -0
- package/skills/wave-executor/references/wave-executor-state-init.md +86 -0
- package/skills/wave-executor/references/wave-loop-dispatch.md +12 -2
- package/skills/wave-executor/references/wave-loop-review.md +19 -6
- package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
- package/templates/_shared/ux-manifest.template.md +149 -0
- package/templates/nextjs-minimal/package.json +1 -1
- package/templates/node-minimal/package.json +1 -1
- package/scripts/lib/multi-provider-build/providers.mjs +0 -64
- package/scripts/lib/multi-provider-build/templating.mjs +0 -130
- package/scripts/lib/owner-config/coerce.mjs +0 -29
- package/scripts/lib/owner-config/constants.mjs +0 -21
- package/scripts/lib/owner-config/defaults.mjs +0 -50
- package/scripts/lib/owner-config/error.mjs +0 -19
- package/scripts/lib/owner-config/index.mjs +0 -13
- package/scripts/lib/owner-config/merge.mjs +0 -52
- package/scripts/lib/owner-config/validate.mjs +0 -259
- package/scripts/lib/owner-config-loader.mjs +0 -170
- package/scripts/lib/owner-config.mjs +0 -28
- package/scripts/lib/soul-resolve.mjs +0 -130
- package/scripts/lib/vault-mirror/render.mjs +0 -8
- package/templates/_shared/journey-manifest.md +0 -114
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/pencil-coverage.mjs — S6 Pencil frame coverage (optional, gated).
|
|
3
|
+
*
|
|
4
|
+
* Spec: docs/prd/2026-09-12-ux-grill.md § 2 S6 and § 3 "Pencil-Abdeckung
|
|
5
|
+
* (optional)" — two acceptance criteria: (a) the run-record reports per route
|
|
6
|
+
* `frame: desktop|mobile|both|none` derived from the `.pen` file's top-level
|
|
7
|
+
* frames, (b) Pen.app unreachable → the step is recorded as
|
|
8
|
+
* `skipped: pencil-unavailable` and the run ends WITHOUT error.
|
|
9
|
+
*
|
|
10
|
+
* **Why this module is a pure classifier.** The Pencil MCP tools
|
|
11
|
+
* ({@link PENCIL_TOOL_NAMES}) are callable only from the coordinator's tool
|
|
12
|
+
* surface, never from a Node process — there is no client for them here. So the
|
|
13
|
+
* division of labour is: the COORDINATOR reads the top-level frames (an
|
|
14
|
+
* `execute` Get visitor at depth 1) and hands them in; this module does the
|
|
15
|
+
* matching and the classification, deterministically and without I/O.
|
|
16
|
+
*
|
|
17
|
+
* Leaf module: imports only `./schema.mjs` (no I/O, no side effects, no node
|
|
18
|
+
* builtins).
|
|
19
|
+
*
|
|
20
|
+
* Exports:
|
|
21
|
+
* PENCIL_TOOL_NAMES, MOBILE_WIDTH_CEILING_PX, FRAME_COVERAGE,
|
|
22
|
+
* classifyFrameCoverage(), pencilSkipped(), describePencilStep()
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { SKIP_REASONS } from './schema.mjs';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The LIVE Pencil MCP tool names — the one place they are spelled in code, so
|
|
29
|
+
* prose (skills, probes, references) can cite this constant instead of keeping
|
|
30
|
+
* a private copy that rots.
|
|
31
|
+
*
|
|
32
|
+
* Measured 2026-09-12 from this session's MCP tool listing, which exposes
|
|
33
|
+
* exactly: `mcp__pencil__browser`, `mcp__pencil__execute`,
|
|
34
|
+
* `mcp__pencil__get_app_state`, `mcp__pencil__get_style`,
|
|
35
|
+
* `mcp__pencil__read_skill`. The pre-2026 surface (`get_editor_state`,
|
|
36
|
+
* `open_document`, `batch_get`, `get_screenshot`) no longer exists — any prose
|
|
37
|
+
* still naming it is dead and was swept in the same change (PRD § 2 S6).
|
|
38
|
+
*
|
|
39
|
+
* Only the three tools this feature needs are named here; `get_style` and
|
|
40
|
+
* `read_skill` exist but have no call site in ux-grill (BV-001.1).
|
|
41
|
+
* @type {Readonly<{appState: string, execute: string, browser: string}>}
|
|
42
|
+
*/
|
|
43
|
+
export const PENCIL_TOOL_NAMES = Object.freeze({
|
|
44
|
+
appState: 'mcp__pencil__get_app_state',
|
|
45
|
+
execute: 'mcp__pencil__execute',
|
|
46
|
+
browser: 'mcp__pencil__browser',
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A top-level frame narrower than this is classified `mobile`, otherwise
|
|
51
|
+
* `desktop`.
|
|
52
|
+
*
|
|
53
|
+
* Named ceiling (BV-004): 600 px sits above every phone frame in common design
|
|
54
|
+
* kits (iPhone 15 = 393, Pixel 8 = 412, iPhone 15 Pro Max = 430) and below every
|
|
55
|
+
* tablet/desktop canvas (768, 1024, 1440). Revisit trigger: the first manifest
|
|
56
|
+
* whose design file carries a deliberate tablet frame — a 768-px frame lands in
|
|
57
|
+
* `desktop` here, which is a coverage answer, not a device taxonomy.
|
|
58
|
+
* @type {number}
|
|
59
|
+
*/
|
|
60
|
+
export const MOBILE_WIDTH_CEILING_PX = 600;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The four coverage values a route can carry in the run-record (PRD § 3).
|
|
64
|
+
* @type {Readonly<Record<string, string>>}
|
|
65
|
+
*/
|
|
66
|
+
export const FRAME_COVERAGE = Object.freeze({
|
|
67
|
+
DESKTOP: 'desktop',
|
|
68
|
+
MOBILE: 'mobile',
|
|
69
|
+
BOTH: 'both',
|
|
70
|
+
NONE: 'none',
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const COVERAGE_VALUES = Object.freeze(Object.values(FRAME_COVERAGE));
|
|
74
|
+
|
|
75
|
+
/** `Dashboard (mobile)` / `Dashboard — desktop` → captures the device word. */
|
|
76
|
+
const DEVICE_SUFFIX = /[([\s—-]\s*(mobile|desktop)\s*\)?\s*$/i;
|
|
77
|
+
|
|
78
|
+
/** Regex metacharacters that make a `title-pattern` something other than a literal. */
|
|
79
|
+
const REGEX_METACHAR = /[.*+?^${}()|[\]\\]/;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalise a frame or route key for case-insensitive comparison: lowercased,
|
|
83
|
+
* trimmed, inner whitespace collapsed. Deliberately NOT slug-folding `-`/`_` —
|
|
84
|
+
* `new-document` and `new document` are different frame names, and silently
|
|
85
|
+
* equating them would report design coverage that does not exist.
|
|
86
|
+
* @param {unknown} value
|
|
87
|
+
* @returns {string} `''` for anything that is not a non-empty string.
|
|
88
|
+
*/
|
|
89
|
+
function normalise(value) {
|
|
90
|
+
if (typeof value !== 'string') return '';
|
|
91
|
+
return value.trim().replace(/\s+/g, ' ').toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The frame name with a trailing `(mobile)` / `(desktop)` marker removed, so
|
|
96
|
+
* `Dashboard (mobile)` matches the route key `dashboard`.
|
|
97
|
+
* @param {unknown} name
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function baseName(name) {
|
|
101
|
+
return normalise(typeof name === 'string' ? name.replace(DEVICE_SUFFIX, '') : '');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Classify ONE frame as `desktop` or `mobile`.
|
|
106
|
+
*
|
|
107
|
+
* Precedence — stated, because the two signals can disagree and a design file
|
|
108
|
+
* is allowed to lie about its width:
|
|
109
|
+
* 1. an explicit `(mobile)` / `(desktop)` suffix in the frame name;
|
|
110
|
+
* 2. otherwise `width < MOBILE_WIDTH_CEILING_PX` → mobile, else desktop;
|
|
111
|
+
* 3. no suffix and no usable width → `desktop`, because a `.pen` top-level
|
|
112
|
+
* frame defaults to the desktop canvas, and a mobile frame is in practice
|
|
113
|
+
* either narrower than the ceiling or suffix-labelled.
|
|
114
|
+
* @param {{name?: unknown, width?: unknown}} frame
|
|
115
|
+
* @returns {'desktop'|'mobile'}
|
|
116
|
+
*/
|
|
117
|
+
function deviceOfFrame(frame) {
|
|
118
|
+
const suffix = typeof frame?.name === 'string' ? DEVICE_SUFFIX.exec(frame.name) : null;
|
|
119
|
+
if (suffix) return suffix[1].toLowerCase() === 'mobile' ? FRAME_COVERAGE.MOBILE : FRAME_COVERAGE.DESKTOP;
|
|
120
|
+
const width = frame?.width;
|
|
121
|
+
if (typeof width === 'number' && Number.isFinite(width) && width > 0) {
|
|
122
|
+
return width < MOBILE_WIDTH_CEILING_PX ? FRAME_COVERAGE.MOBILE : FRAME_COVERAGE.DESKTOP;
|
|
123
|
+
}
|
|
124
|
+
return FRAME_COVERAGE.DESKTOP;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Fold a set of matched frames into one coverage value.
|
|
129
|
+
* @param {Array<object>} matched
|
|
130
|
+
* @returns {string} a value of {@link FRAME_COVERAGE}.
|
|
131
|
+
*/
|
|
132
|
+
function coverageOf(matched) {
|
|
133
|
+
if (matched.length === 0) return FRAME_COVERAGE.NONE;
|
|
134
|
+
let hasDesktop = false;
|
|
135
|
+
let hasMobile = false;
|
|
136
|
+
for (const frame of matched) {
|
|
137
|
+
if (deviceOfFrame(frame) === FRAME_COVERAGE.MOBILE) hasMobile = true;
|
|
138
|
+
else hasDesktop = true;
|
|
139
|
+
}
|
|
140
|
+
if (hasDesktop && hasMobile) return FRAME_COVERAGE.BOTH;
|
|
141
|
+
return hasMobile ? FRAME_COVERAGE.MOBILE : FRAME_COVERAGE.DESKTOP;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The comparison keys a route offers for name matching: the last segment of
|
|
146
|
+
* `path`, plus `title-pattern` when it is a plain literal.
|
|
147
|
+
*
|
|
148
|
+
* A `title-pattern` is a REGEX in the manifest (PRD § 2 S1). Only an
|
|
149
|
+
* anchor-only pattern (`^Dashboard`, `Dashboard$`) is usable as a literal name;
|
|
150
|
+
* anything carrying further metacharacters is skipped rather than matched
|
|
151
|
+
* approximately — a wrong match reports design coverage that does not exist.
|
|
152
|
+
* @param {{path?: unknown, ['title-pattern']?: unknown}} route
|
|
153
|
+
* @returns {string[]} normalised, de-duplicated, never containing `''`.
|
|
154
|
+
*/
|
|
155
|
+
function routeKeys(route) {
|
|
156
|
+
const keys = [];
|
|
157
|
+
if (typeof route?.path === 'string') {
|
|
158
|
+
const segments = route.path.split('/').filter((s) => s.length > 0);
|
|
159
|
+
keys.push(normalise(segments.length > 0 ? segments[segments.length - 1] : route.path));
|
|
160
|
+
}
|
|
161
|
+
const pattern = route?.['title-pattern'];
|
|
162
|
+
if (typeof pattern === 'string') {
|
|
163
|
+
const stripped = pattern.replace(/^\^/, '').replace(/\$$/, '');
|
|
164
|
+
if (stripped.length > 0 && !REGEX_METACHAR.test(stripped)) keys.push(normalise(stripped));
|
|
165
|
+
}
|
|
166
|
+
return [...new Set(keys.filter((k) => k.length > 0))];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Classify per-route design-frame coverage (PRD § 2 S6, AC "Pencil-Abdeckung").
|
|
171
|
+
*
|
|
172
|
+
* Matching precedence, highest first:
|
|
173
|
+
* 1. **`routes[].frame` as a coverage literal** — `desktop|mobile|both|none`
|
|
174
|
+
* written straight into the manifest (the shape
|
|
175
|
+
* `templates/_shared/ux-manifest.template.md` ships) is taken as the
|
|
176
|
+
* answer; `matchedBy: 'routes[].frame'`.
|
|
177
|
+
* 2. **`routes[].frame` as a reference** — any other value is a frame `id`
|
|
178
|
+
* (exact) or frame `name` (case-insensitive, `(mobile)`/`(desktop)` suffix
|
|
179
|
+
* ignored). Resolving to nothing yields `none` with
|
|
180
|
+
* `matchedBy: 'routes[].frame'`: the declaration WAS honoured, the frame is
|
|
181
|
+
* absent — distinguishable from a route that declared nothing.
|
|
182
|
+
* 3. **Name equality** — case-insensitive equality between a
|
|
183
|
+
* {@link routeKeys} key and a frame's base name; `matchedBy: 'name'`.
|
|
184
|
+
* 4. Otherwise `none` with `matchedBy: null`.
|
|
185
|
+
*
|
|
186
|
+
* Pure: no I/O, no mutation of the inputs.
|
|
187
|
+
*
|
|
188
|
+
* @param {object} opts
|
|
189
|
+
* @param {Array<{path: string, frame?: string, ['title-pattern']?: string}>} opts.routes
|
|
190
|
+
* the manifest's `routes[]`.
|
|
191
|
+
* @param {Array<{id?: string, name?: string, width?: number, height?: number}>} [opts.frames]
|
|
192
|
+
* the `.pen` file's TOP-LEVEL frames, as obtained by the coordinator via
|
|
193
|
+
* `PENCIL_TOOL_NAMES.execute` with a depth-1 Get visitor. Defaults to `[]`,
|
|
194
|
+
* which classifies every route as `none` — the honest answer for "the design
|
|
195
|
+
* file has no top-level frames", NOT the same thing as a skipped step (see
|
|
196
|
+
* {@link pencilSkipped}).
|
|
197
|
+
* @returns {Array<{route: string, frame: string, matchedBy: string|null}>} one
|
|
198
|
+
* entry per route, input order preserved. `{route, frame}` is exactly the
|
|
199
|
+
* `pencilCoverage` shape `makeRunRecord()` accepts; `matchedBy` is extra
|
|
200
|
+
* provenance for the report and is ignored by the run-record.
|
|
201
|
+
* @throws {TypeError} on any invalid input — no silent defaults.
|
|
202
|
+
*/
|
|
203
|
+
export function classifyFrameCoverage({ routes, frames = [] } = {}) {
|
|
204
|
+
if (!Array.isArray(routes)) {
|
|
205
|
+
throw new TypeError('classifyFrameCoverage: routes must be an array');
|
|
206
|
+
}
|
|
207
|
+
if (!Array.isArray(frames)) {
|
|
208
|
+
throw new TypeError('classifyFrameCoverage: frames must be an array when provided');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return routes.map((route, index) => {
|
|
212
|
+
if (route === null || typeof route !== 'object' || Array.isArray(route)) {
|
|
213
|
+
throw new TypeError(`classifyFrameCoverage: routes[${index}] must be a plain object`);
|
|
214
|
+
}
|
|
215
|
+
if (typeof route.path !== 'string' || route.path.length === 0) {
|
|
216
|
+
throw new TypeError(`classifyFrameCoverage: routes[${index}].path must be a non-empty string`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const declared = typeof route.frame === 'string' ? route.frame.trim() : '';
|
|
220
|
+
|
|
221
|
+
if (declared.length > 0) {
|
|
222
|
+
if (COVERAGE_VALUES.includes(declared.toLowerCase())) {
|
|
223
|
+
return { route: route.path, frame: declared.toLowerCase(), matchedBy: 'routes[].frame' };
|
|
224
|
+
}
|
|
225
|
+
const wanted = normalise(declared);
|
|
226
|
+
const matched = frames.filter(
|
|
227
|
+
(frame) => frame?.id === declared || baseName(frame?.name) === wanted || normalise(frame?.name) === wanted,
|
|
228
|
+
);
|
|
229
|
+
return { route: route.path, frame: coverageOf(matched), matchedBy: 'routes[].frame' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const keys = routeKeys(route);
|
|
233
|
+
const matched = frames.filter((frame) => {
|
|
234
|
+
const name = baseName(frame?.name);
|
|
235
|
+
return name.length > 0 && keys.includes(name);
|
|
236
|
+
});
|
|
237
|
+
if (matched.length === 0) return { route: route.path, frame: FRAME_COVERAGE.NONE, matchedBy: null };
|
|
238
|
+
return { route: route.path, frame: coverageOf(matched), matchedBy: 'name' };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The `skipped[]` record for an unavailable Pencil step (PRD § 3 AC 2).
|
|
244
|
+
*
|
|
245
|
+
* Returned rather than thrown on purpose: the run must end without error when
|
|
246
|
+
* Pen.app or its MCP surface is unreachable, and a skip that is RECORDED is
|
|
247
|
+
* what keeps "no coverage measured" from reading as "no design frames".
|
|
248
|
+
*
|
|
249
|
+
* @param {string} [reason] - a value of `SKIP_REASONS`; defaults to
|
|
250
|
+
* `SKIP_REASONS.PENCIL_UNAVAILABLE`.
|
|
251
|
+
* @returns {{what: string, reason: string}} accepted by `makeRunRecord({skipped})`.
|
|
252
|
+
* @throws {TypeError} if `reason` is not a known skip reason.
|
|
253
|
+
*/
|
|
254
|
+
export function pencilSkipped(reason = SKIP_REASONS.PENCIL_UNAVAILABLE) {
|
|
255
|
+
if (!Object.values(SKIP_REASONS).includes(reason)) {
|
|
256
|
+
throw new TypeError(`pencilSkipped: unknown skip reason ${String(reason)}`);
|
|
257
|
+
}
|
|
258
|
+
return { what: 'pencil', reason };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Whether the optional Pencil step runs at all, per the manifest (PRD § 2 S6:
|
|
263
|
+
* "if `pencil.file` is set and Pen.app is reachable"). This answers only the
|
|
264
|
+
* first half — reachability is the coordinator's to determine, by calling
|
|
265
|
+
* `PENCIL_TOOL_NAMES.appState`.
|
|
266
|
+
*
|
|
267
|
+
* @param {object} opts
|
|
268
|
+
* @param {{frontmatter?: object}|object} opts.manifest - a `loadManifest()` /
|
|
269
|
+
* `parseManifest()` result, or a bare frontmatter object (both accepted: a
|
|
270
|
+
* bare frontmatter would otherwise silently answer `enabled: false`).
|
|
271
|
+
* @returns {{enabled: boolean, file: string|null}} `file` is the manifest value
|
|
272
|
+
* verbatim — a repo-relative path that the caller resolves; this module never
|
|
273
|
+
* touches the filesystem.
|
|
274
|
+
*/
|
|
275
|
+
export function describePencilStep({ manifest } = {}) {
|
|
276
|
+
const source = manifest && typeof manifest === 'object' && !Array.isArray(manifest) ? manifest : {};
|
|
277
|
+
const frontmatter =
|
|
278
|
+
source.frontmatter && typeof source.frontmatter === 'object' && !Array.isArray(source.frontmatter)
|
|
279
|
+
? source.frontmatter
|
|
280
|
+
: source;
|
|
281
|
+
const file = frontmatter?.pencil?.file;
|
|
282
|
+
if (typeof file !== 'string' || file.trim().length === 0) return { enabled: false, file: null };
|
|
283
|
+
return { enabled: true, file: file.trim() };
|
|
284
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ux-grill/reconcile.mjs — route Stufe-1 findings to the issue tracker
|
|
3
|
+
* (PRD § 2 S5, § 3 "Reconcile & Regression" AC 2).
|
|
4
|
+
*
|
|
5
|
+
* This module names NO tracker binary. Every tracker interaction goes
|
|
6
|
+
* through `scripts/lib/test-runner/issue-reconcile.mjs`, which owns the binary
|
|
7
|
+
* allowlist, the `execFile`-not-shell rule (ADR-364 §C5), the arg-boundary
|
|
8
|
+
* validation and the 64 KiB body cap. Grepping this file for the binary name
|
|
9
|
+
* must return zero — that is the invariant, not a coincidence, and it is why the
|
|
10
|
+
* budget gate below reconstructs its command string WITHOUT the binary (the
|
|
11
|
+
* exemption regexes in `issue-budget.mjs` match labels and title templates only,
|
|
12
|
+
* never the program name — measured 2026-09-12 against its `EXEMPT_RULES`).
|
|
13
|
+
*
|
|
14
|
+
* SEVERITY ROUTING (`skills/test-runner/SKILL.md` § Severity Routing / § Batched
|
|
15
|
+
* AUQ Triage, mechanised here):
|
|
16
|
+
* - `high` (and `critical`, which ux-grill Stufe 1 never emits) → auto-routed
|
|
17
|
+
* through `triageDecision` → `createFinding` / `updateFinding`.
|
|
18
|
+
* - `medium` / `low` → NEVER created here. They are returned as `batch` for the
|
|
19
|
+
* coordinator's ONE bundled AUQ. An agent cannot call `AskUserQuestion`, so
|
|
20
|
+
* the decision has to travel upward as data.
|
|
21
|
+
* - `provisional: true` → `batch`, whatever its severity. A dev-build geometry
|
|
22
|
+
* number is a measurement taken on a basis the PRD declares invalid
|
|
23
|
+
* (`schema.mjs` § makeFinding), so filing it as a product defect would put a
|
|
24
|
+
* bundler artefact in the tracker under a `priority::high` label.
|
|
25
|
+
*
|
|
26
|
+
* ISSUE BUDGET. `createFinding` spawns the tracker CLI via `execFile` with
|
|
27
|
+
* `shell: false`, so the `PreToolUse`/Bash hook `hooks/pre-bash-issue-budget.mjs`
|
|
28
|
+
* NEVER sees it —
|
|
29
|
+
* exactly the programmatic hole `scripts/lib/spiral-carryover.mjs` closes by
|
|
30
|
+
* calling `chargeIssueBudget` itself. This module does the same, with the same
|
|
31
|
+
* counting contract and the same ledger: there is one counter, not a second one.
|
|
32
|
+
* A blocked creation is not dropped — it moves to `batch` (so the operator still
|
|
33
|
+
* sees it) and increments `budgetStops`.
|
|
34
|
+
*
|
|
35
|
+
* `dryRun` DEFAULTS TO TRUE. The skill flips it explicitly after the operator has
|
|
36
|
+
* seen the plan. A reconcile helper whose default writes to a live tracker is one
|
|
37
|
+
* mistaken import away from filing issues nobody asked for.
|
|
38
|
+
*
|
|
39
|
+
* Exports:
|
|
40
|
+
* UX_GRILL_LABELS, buildIssueTitle, buildIssueBody, reconcileFindings
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
createFinding as defaultCreateFinding,
|
|
45
|
+
listExistingFindings as defaultListExisting,
|
|
46
|
+
triageDecision,
|
|
47
|
+
updateFinding as defaultUpdateFinding,
|
|
48
|
+
} from '../test-runner/issue-reconcile.mjs';
|
|
49
|
+
import { chargeIssueBudget } from '../issue-budget.mjs';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Labels applied to every auto-created ux-grill issue.
|
|
53
|
+
*
|
|
54
|
+
* Spelling follows `skills/gitlab-ops/SKILL.md` § Label Taxonomy EXACTLY:
|
|
55
|
+
* `from:` is single-colon, `priority::` is double-colon (measured there:
|
|
56
|
+
* 416 `priority::` against 249 `priority:` on the instance). Only `high`
|
|
57
|
+
* findings are auto-created, so the priority label is constant.
|
|
58
|
+
* @type {string}
|
|
59
|
+
*/
|
|
60
|
+
export const UX_GRILL_LABELS = 'from:ux-grill,priority::high';
|
|
61
|
+
|
|
62
|
+
/** Label used to QUERY already-filed ux-grill issues (the `from:` axis alone). */
|
|
63
|
+
export const UX_GRILL_QUERY_LABEL = 'from:ux-grill';
|
|
64
|
+
|
|
65
|
+
/** GitLab issue titles are capped well above this; 100 keeps a list readable. */
|
|
66
|
+
const TITLE_MAX_LENGTH = 100;
|
|
67
|
+
|
|
68
|
+
/** Severities that are auto-routed to the tracker. `critical` is future-proofing. */
|
|
69
|
+
const AUTO_SEVERITIES = Object.freeze(['critical', 'high']);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Neutralise a fingerprint-sentinel literal in free text (#388 SEC-IR-MED-1).
|
|
73
|
+
*
|
|
74
|
+
* `issue-reconcile.mjs` applies this to `recommendation` only, inside
|
|
75
|
+
* `reconcileFinding` — a body built HERE and handed to `createFinding` is not
|
|
76
|
+
* sanitised by anything, so a finding `message` echoing page text could forge
|
|
77
|
+
* the authoritative `**Fingerprint:**` line and make the next run's
|
|
78
|
+
* `triageDecision` dedup against the wrong issue.
|
|
79
|
+
*
|
|
80
|
+
* @param {unknown} text
|
|
81
|
+
* @returns {string}
|
|
82
|
+
*/
|
|
83
|
+
function sanitizeSentinel(text) {
|
|
84
|
+
return String(text ?? '').replace(/\*\*Fingerprint:\*\*/gi, '__Fingerprint__');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Strip the characters `createFinding` rejects at the argv boundary, so a
|
|
89
|
+
* multi-line page title in a finding message cannot turn into a VALIDATION
|
|
90
|
+
* failure for the whole reconcile pass.
|
|
91
|
+
*
|
|
92
|
+
* @param {unknown} text
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
function oneLine(text) {
|
|
96
|
+
return sanitizeSentinel(text).replace(/[\n\r\0]+/g, ' ').trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* `route|viewport` — the first two locator segments (`schema.mjs` § makeFinding).
|
|
101
|
+
* @param {string} locator
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function locatorScope(locator) {
|
|
105
|
+
return String(locator ?? '').split('|').slice(0, 2).join('|');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Build the issue title: `[ux-grill] <checkId> — <route|viewport>`, capped at 100.
|
|
110
|
+
*
|
|
111
|
+
* The title is a `triageDecision` input (fuzzy Levenshtein ≤ 2 match), so it must
|
|
112
|
+
* stay stable across runs for one finding — hence checkId + scope and NOT the
|
|
113
|
+
* measured value, which changes with every layout tweak and would make every run
|
|
114
|
+
* look like a new issue.
|
|
115
|
+
*
|
|
116
|
+
* @param {{checkId: string, locator: string}} finding
|
|
117
|
+
* @returns {string}
|
|
118
|
+
*/
|
|
119
|
+
export function buildIssueTitle(finding) {
|
|
120
|
+
const title = `[ux-grill] ${oneLine(finding?.checkId)} — ${oneLine(locatorScope(finding?.locator))}`;
|
|
121
|
+
return title.slice(0, TITLE_MAX_LENGTH);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the issue body.
|
|
126
|
+
*
|
|
127
|
+
* Carries the authoritative `**Fingerprint:** \`<fp>\`` sentinel in the exact
|
|
128
|
+
* shape `issue-reconcile.mjs`'s `extractFingerprintFromBody` matches —
|
|
129
|
+
* `createFinding` does NOT append it (verified 2026-09-12: its `args` are
|
|
130
|
+
* `['issue','create','--title',…,'--description',body]`, the `fingerprint`
|
|
131
|
+
* parameter is validated and otherwise unused). Without this line the next run
|
|
132
|
+
* cannot dedup and files the same finding again.
|
|
133
|
+
*
|
|
134
|
+
* WHAT MUST NEVER BE IN HERE: an env value (the manifest resolves credentials by
|
|
135
|
+
* ENV NAME and `collect.mjs` never puts them in a finding) and an absolute host
|
|
136
|
+
* path. Evidence paths are already run-dir-relative — `collect()` stores
|
|
137
|
+
* `path.relative(runDir, …)` — and {@link reconcileFindings} rejects a finding
|
|
138
|
+
* carrying an absolute one rather than publishing an operator's home directory
|
|
139
|
+
* to a tracker.
|
|
140
|
+
*
|
|
141
|
+
* @param {object} finding - a `makeFinding` record
|
|
142
|
+
* @param {object} [context]
|
|
143
|
+
* @param {string} [context.runId]
|
|
144
|
+
* @param {string} [context.rubricHash]
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function buildIssueBody(finding, { runId, rubricHash } = {}) {
|
|
148
|
+
const evidence = finding?.evidence ?? {};
|
|
149
|
+
const evidenceLines = Object.entries(evidence)
|
|
150
|
+
.filter(([, value]) => typeof value === 'string' || Array.isArray(value))
|
|
151
|
+
.map(([key, value]) => `- \`${key}\`: ${oneLine(Array.isArray(value) ? value.join(', ') : value)}`);
|
|
152
|
+
|
|
153
|
+
return [
|
|
154
|
+
oneLine(finding?.message) || `${oneLine(finding?.checkId)} at ${oneLine(finding?.locator)}`,
|
|
155
|
+
'',
|
|
156
|
+
`**Fingerprint:** \`${oneLine(finding?.fingerprint)}\``,
|
|
157
|
+
`**Severity:** ${oneLine(finding?.severity)}`,
|
|
158
|
+
`**Check:** ${oneLine(finding?.checkId)}`,
|
|
159
|
+
`**Locator:** \`${oneLine(finding?.locator)}\``,
|
|
160
|
+
`**Provisional:** ${finding?.provisional === true}`,
|
|
161
|
+
runId ? `**Run:** \`${oneLine(runId)}\`` : null,
|
|
162
|
+
rubricHash ? `**Rubric hash:** \`${oneLine(rubricHash)}\`` : null,
|
|
163
|
+
evidenceLines.length > 0 ? '' : null,
|
|
164
|
+
evidenceLines.length > 0 ? '**Evidence** (paths are relative to the run directory):' : null,
|
|
165
|
+
...evidenceLines,
|
|
166
|
+
]
|
|
167
|
+
.filter((line) => line !== null)
|
|
168
|
+
.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Collect every string in a finding's `evidence` that looks like an absolute
|
|
173
|
+
* host path (POSIX `/…` or Windows `C:\…`).
|
|
174
|
+
*
|
|
175
|
+
* @param {object} finding
|
|
176
|
+
* @returns {string[]}
|
|
177
|
+
*/
|
|
178
|
+
function absoluteEvidencePaths(finding) {
|
|
179
|
+
const out = [];
|
|
180
|
+
for (const value of Object.values(finding?.evidence ?? {})) {
|
|
181
|
+
for (const entry of Array.isArray(value) ? value : [value]) {
|
|
182
|
+
if (typeof entry !== 'string') continue;
|
|
183
|
+
if (entry.startsWith('/') || /^[A-Za-z]:[\\/]/.test(entry)) out.push(entry);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return out;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Default budget gate — the SAME `chargeIssueBudget` decision the Bash hook and
|
|
191
|
+
* `spiral-carryover.mjs` apply, against the same per-session ledger.
|
|
192
|
+
*
|
|
193
|
+
* @param {{repoRoot: string, sessionId: string|null, title: string}} opts
|
|
194
|
+
* @returns {{decision: string, count: number, max: number, reason?: string|null}}
|
|
195
|
+
*/
|
|
196
|
+
function defaultBudget({ repoRoot, sessionId, title }) {
|
|
197
|
+
// classifyExemption() reads a COMMAND string; reconstruct the argv this
|
|
198
|
+
// creation is about to become so exemption classification (`priority::critical`,
|
|
199
|
+
// carryover) sees what it would have seen on the shell path.
|
|
200
|
+
const command = `issue create --title ${JSON.stringify(title)} --label ${UX_GRILL_LABELS}`;
|
|
201
|
+
return chargeIssueBudget({ repoRoot, sessionId, command, title });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Route a run's findings to the issue tracker.
|
|
206
|
+
*
|
|
207
|
+
* @param {object} opts
|
|
208
|
+
* @param {string} opts.repoRoot - absolute repo root (ledger root AND the cwd
|
|
209
|
+
* `issue-reconcile.mjs` auto-detects the `--repo` spec from)
|
|
210
|
+
* @param {string} [opts.runId] - stamped into every body
|
|
211
|
+
* @param {string} [opts.rubricHash] - stamped into every body
|
|
212
|
+
* @param {Array<object>} opts.findings - `makeFinding` records
|
|
213
|
+
* @param {string} [opts.project] - explicit GitLab project path; omitted → the
|
|
214
|
+
* spine auto-detects it from `repoRoot`'s git remotes
|
|
215
|
+
* @param {boolean} [opts.dryRun=true] - TRUE by default; see module header
|
|
216
|
+
* @param {Function} [opts.execFile] - DI seam handed straight to the spine
|
|
217
|
+
* @param {string|null} [opts.sessionId=null] - issue-budget accounting key
|
|
218
|
+
* @param {Function} [opts.budget] - budget gate; defaults to `chargeIssueBudget`
|
|
219
|
+
* @param {Function} [opts.listExisting=listExistingFindings]
|
|
220
|
+
* @param {Function} [opts.create=createFinding]
|
|
221
|
+
* @param {Function} [opts.update=updateFinding]
|
|
222
|
+
* @returns {Promise<{
|
|
223
|
+
* created: object[], updated: object[], ignored: object[], batch: object[],
|
|
224
|
+
* errors: object[], budgetStops: number, dryRun: boolean
|
|
225
|
+
* }>} `batch` is the medium/low + provisional + budget-stopped set the
|
|
226
|
+
* coordinator turns into ONE AUQ. `errors` carries per-finding refusals and a
|
|
227
|
+
* tracker-query failure (`code: 'list-failed'`) — a failed query means the
|
|
228
|
+
* dedup set is unknown, so NOTHING is created and every auto finding moves to
|
|
229
|
+
* `batch` rather than risking duplicates.
|
|
230
|
+
* @throws {TypeError} on a missing `repoRoot` or a non-array `findings`
|
|
231
|
+
*/
|
|
232
|
+
export async function reconcileFindings({
|
|
233
|
+
repoRoot,
|
|
234
|
+
runId,
|
|
235
|
+
rubricHash,
|
|
236
|
+
findings,
|
|
237
|
+
project,
|
|
238
|
+
dryRun = true,
|
|
239
|
+
execFile,
|
|
240
|
+
sessionId = null,
|
|
241
|
+
budget = defaultBudget,
|
|
242
|
+
listExisting = defaultListExisting,
|
|
243
|
+
create = defaultCreateFinding,
|
|
244
|
+
update = defaultUpdateFinding,
|
|
245
|
+
} = {}) {
|
|
246
|
+
if (typeof repoRoot !== 'string' || repoRoot.length === 0) {
|
|
247
|
+
throw new TypeError('reconcileFindings: repoRoot must be a non-empty string');
|
|
248
|
+
}
|
|
249
|
+
if (!Array.isArray(findings)) {
|
|
250
|
+
throw new TypeError('reconcileFindings: findings must be an array');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const created = [];
|
|
254
|
+
const updated = [];
|
|
255
|
+
const ignored = [];
|
|
256
|
+
const batch = [];
|
|
257
|
+
const errors = [];
|
|
258
|
+
let budgetStops = 0;
|
|
259
|
+
|
|
260
|
+
const auto = [];
|
|
261
|
+
for (const finding of findings) {
|
|
262
|
+
const isAuto = AUTO_SEVERITIES.includes(finding?.severity) && finding?.provisional !== true;
|
|
263
|
+
(isAuto ? auto : batch).push(finding);
|
|
264
|
+
}
|
|
265
|
+
// Deterministic order: the budget cap decides WHICH findings get filed, so the
|
|
266
|
+
// order must not depend on how the findings array happened to be built.
|
|
267
|
+
auto.sort((a, b) => (a?.fingerprint < b?.fingerprint ? -1 : a?.fingerprint > b?.fingerprint ? 1 : 0));
|
|
268
|
+
|
|
269
|
+
if (auto.length === 0) {
|
|
270
|
+
return { created, updated, ignored, batch, errors, budgetStops, dryRun };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const existing = await listExisting({
|
|
274
|
+
project,
|
|
275
|
+
label: UX_GRILL_QUERY_LABEL,
|
|
276
|
+
repoRoot,
|
|
277
|
+
execFile,
|
|
278
|
+
});
|
|
279
|
+
if (!existing?.ok) {
|
|
280
|
+
errors.push({ code: 'list-failed', error: existing?.error ?? null });
|
|
281
|
+
batch.push(...auto);
|
|
282
|
+
return { created, updated, ignored, batch, errors, budgetStops, dryRun };
|
|
283
|
+
}
|
|
284
|
+
const candidates = existing.issues ?? [];
|
|
285
|
+
|
|
286
|
+
for (const finding of auto) {
|
|
287
|
+
const leaked = absoluteEvidencePaths(finding);
|
|
288
|
+
if (leaked.length > 0) {
|
|
289
|
+
errors.push({
|
|
290
|
+
code: 'absolute-evidence-path',
|
|
291
|
+
fingerprint: finding?.fingerprint ?? null,
|
|
292
|
+
paths: leaked,
|
|
293
|
+
});
|
|
294
|
+
batch.push(finding);
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const title = buildIssueTitle(finding);
|
|
299
|
+
const body = buildIssueBody(finding, { runId, rubricHash });
|
|
300
|
+
const decision = triageDecision({ fingerprint: finding?.fingerprint, title }, candidates);
|
|
301
|
+
|
|
302
|
+
if (decision.action === 'ignore') {
|
|
303
|
+
ignored.push({ finding, decision });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (decision.action === 'update') {
|
|
308
|
+
const result = await update({
|
|
309
|
+
project,
|
|
310
|
+
iid: decision.target,
|
|
311
|
+
comment: `Still present in ux-grill run \`${oneLine(runId)}\`.\n\n${body}`,
|
|
312
|
+
dryRun,
|
|
313
|
+
execFile,
|
|
314
|
+
repoRoot,
|
|
315
|
+
});
|
|
316
|
+
if (result?.ok) updated.push({ finding, decision, iid: decision.target, result });
|
|
317
|
+
else errors.push({ code: 'update-failed', fingerprint: finding?.fingerprint ?? null, error: result?.error ?? null });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// create — the only action the budget bounds.
|
|
322
|
+
const verdict = budget({ repoRoot, sessionId, title, finding });
|
|
323
|
+
if (verdict?.decision === 'block') {
|
|
324
|
+
budgetStops += 1;
|
|
325
|
+
batch.push(finding);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const result = await create({
|
|
330
|
+
project,
|
|
331
|
+
fingerprint: finding?.fingerprint,
|
|
332
|
+
title,
|
|
333
|
+
body,
|
|
334
|
+
labels: UX_GRILL_LABELS,
|
|
335
|
+
dryRun,
|
|
336
|
+
execFile,
|
|
337
|
+
repoRoot,
|
|
338
|
+
});
|
|
339
|
+
if (result?.ok) created.push({ finding, decision, result, budget: verdict?.decision ?? null });
|
|
340
|
+
else errors.push({ code: 'create-failed', fingerprint: finding?.fingerprint ?? null, error: result?.error ?? null });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { created, updated, ignored, batch, errors, budgetStops, dryRun };
|
|
344
|
+
}
|