mandrel 2.24.0 → 2.26.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/agents/acceptance-critic.md +19 -21
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/baselines.md +21 -0
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/quality-gates.md +80 -18
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/audit-rules.json +15 -0
- package/.agents/schemas/baselines/audit-baselines-envelope.schema.json +242 -0
- package/.agents/schemas/baselines/baseline-envelope.schema.json +8 -0
- package/.agents/schemas/baselines/crap.schema.json +13 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/model-attribution.schema.json +4 -0
- package/.agents/scripts/acceptance-eval.js +124 -15
- package/.agents/scripts/audit-baselines.js +136 -0
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-arch-cycles.js +12 -93
- package/.agents/scripts/check-baseline-drift.js +16 -3
- package/.agents/scripts/check-baselines.js +19 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-cyclomatic.js +214 -0
- package/.agents/scripts/check-schema-references.js +392 -0
- package/.agents/scripts/check-test-temp-hygiene.js +121 -1
- package/.agents/scripts/check-workflow-timeouts.js +291 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +3 -4
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/diagnose-friction.js +85 -19
- package/.agents/scripts/lib/audit-baselines/engine.js +177 -0
- package/.agents/scripts/lib/audit-baselines/gate-surface.js +63 -0
- package/.agents/scripts/lib/audit-baselines/headroom.js +72 -0
- package/.agents/scripts/lib/audit-baselines/hotspots.js +69 -0
- package/.agents/scripts/lib/audit-baselines/kinds.js +313 -0
- package/.agents/scripts/lib/audit-baselines/outliers.js +100 -0
- package/.agents/scripts/lib/audit-baselines/read.js +87 -0
- package/.agents/scripts/lib/audit-baselines/staleness.js +123 -0
- package/.agents/scripts/lib/audit-baselines/surface-entry.js +106 -0
- package/.agents/scripts/lib/audit-baselines/trend.js +125 -0
- package/.agents/scripts/lib/audit-baselines/weights.js +193 -0
- package/.agents/scripts/lib/audit-suite/index.js +0 -5
- package/.agents/scripts/lib/audit-suite/selector.js +9 -62
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/audit-lenses.js +1 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baseline-schema-registry.js +13 -1
- package/.agents/scripts/lib/baselines/diff-scope-cli.js +22 -160
- package/.agents/scripts/lib/baselines/duplication-scanner.js +27 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +499 -57
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/refresh-service.js +69 -11
- package/.agents/scripts/lib/baselines/scope.js +39 -90
- package/.agents/scripts/lib/baselines/writer.js +16 -7
- package/.agents/scripts/lib/changed-files.js +8 -1
- package/.agents/scripts/lib/cli-args.js +115 -1
- package/.agents/scripts/lib/close-validation/runner.js +70 -25
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +124 -27
- package/.agents/scripts/lib/crap-method-identity.js +153 -0
- package/.agents/scripts/lib/crap-utils.js +86 -13
- package/.agents/scripts/lib/cyclomatic-ceiling.js +265 -0
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +0 -2
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +0 -2
- package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +0 -2
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/git-utils.js +136 -80
- package/.agents/scripts/lib/import-graph.js +156 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/observability/runtime-friction.js +17 -2
- package/.agents/scripts/lib/observability/source-classifier.js +175 -2
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ceremony-routing.js +17 -12
- package/.agents/scripts/lib/orchestration/check-baselines/phases/compare.js +36 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +5 -0
- package/.agents/scripts/lib/orchestration/check-baselines/phases/floors.js +12 -1
- package/.agents/scripts/lib/orchestration/check-baselines/phases/report.js +8 -1
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/phase-drivers.js +10 -5
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/render.js +39 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +20 -3
- package/.agents/scripts/lib/orchestration/light-escalation.js +82 -3
- package/.agents/scripts/lib/orchestration/light-suitability.js +103 -5
- package/.agents/scripts/lib/orchestration/plan-context.js +119 -66
- package/.agents/scripts/lib/orchestration/plan-persist/fan-out-gate.js +31 -5
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +216 -109
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +61 -13
- package/.agents/scripts/lib/orchestration/plan-persist/supersede-ops.js +79 -22
- package/.agents/scripts/lib/orchestration/plan-text-hygiene.js +51 -20
- package/.agents/scripts/lib/orchestration/planning/authoring-context.js +70 -74
- package/.agents/scripts/lib/orchestration/planning/memory-pool-advisory.js +231 -0
- package/.agents/scripts/lib/orchestration/resolve-stories.js +18 -17
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-epilogue.js +12 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +125 -37
- package/.agents/scripts/lib/orchestration/single-story-close/phases/normalize-pr-title.js +6 -6
- package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +42 -38
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/phases/push.js +6 -1
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +267 -125
- package/.agents/scripts/lib/orchestration/spec-budget.js +16 -5
- package/.agents/scripts/lib/orchestration/story-follow-ups.js +182 -95
- package/.agents/scripts/lib/orchestration/ticket-validator-conflicts.js +22 -0
- package/.agents/scripts/lib/orchestration/ticket-validator.js +5 -11
- package/.agents/scripts/lib/orchestration/ticketing/reads.js +4 -4
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-adjacency.js +3 -3
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/test-runner-contract.js +134 -0
- package/.agents/scripts/lib/test-tiers.js +11 -2
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/concurrent-map.js +17 -0
- package/.agents/scripts/lib/util/parse-id-list.js +103 -0
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +29 -13
- package/.agents/scripts/lib/wave-runner/ready-set.js +254 -43
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +10 -13
- package/.agents/scripts/lib/workers/crap-worker.js +8 -13
- package/.agents/scripts/lib/workers/maintainability-report-worker.js +4 -10
- package/.agents/scripts/lib/workers/maintainability-worker.js +4 -10
- package/.agents/scripts/lib/workers/serve-worker-messages.js +35 -0
- package/.agents/scripts/lib/worktree/git-hooks.js +206 -0
- package/.agents/scripts/lib/worktree/lifecycle/creation.js +6 -0
- package/.agents/scripts/lib/worktree-manager.js +14 -0
- package/.agents/scripts/plan-run-epilogue.js +17 -5
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/providers/github/tickets.js +33 -10
- package/.agents/scripts/provision-git-hooks.js +85 -0
- package/.agents/scripts/quality-preview.js +137 -28
- package/.agents/scripts/resolve-stories.js +4 -1
- package/.agents/scripts/run-coverage.js +86 -35
- package/.agents/scripts/run-lint.js +20 -0
- package/.agents/scripts/run-tests.js +36 -36
- package/.agents/scripts/single-story-close.js +34 -2
- package/.agents/scripts/single-story-confirm-merge.js +22 -6
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +308 -47
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-coverage-baseline.js +34 -4
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-duplication-baseline.js +209 -83
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/scripts/validate-docs-freshness.js +1 -0
- package/.agents/skills/core/diagnose-friction/SKILL.md +4 -1
- package/.agents/skills/core/gates-and-baselines/SKILL.md +17 -11
- package/.agents/skills/skills.index.json +2 -2
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-baselines.md +289 -0
- package/.agents/workflows/audit-navigability.md +5 -4
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +92 -97
- package/.agents/workflows/helpers/acceptance-self-eval.md +47 -10
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/code-quality-guardrails.md +9 -2
- package/.agents/workflows/helpers/deliver-digest.md +41 -21
- package/.agents/workflows/helpers/deliver-reference.md +95 -4
- package/.agents/workflows/helpers/deliver-story-reference.md +174 -52
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +49 -5
- package/.agents/workflows/memory-consolidate.md +116 -0
- package/.agents/workflows/plan.md +62 -75
- package/README.md +13 -6
- package/docs/CHANGELOG.md +93 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +9 -4
- package/.agents/schemas/friction-event.schema.json +0 -56
- package/.agents/scripts/lib/feedback-loop/memory-freshness.js +0 -707
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI: GitHub Actions job-timeout gate.
|
|
3
|
+
*
|
|
4
|
+
* Story #4936. Every job in this repository inherited GitHub's 360-minute
|
|
5
|
+
* default because not one of the four workflow files set `timeout-minutes`.
|
|
6
|
+
* That is not a theoretical cost: a deadlocked `Windows Smoke` job burned 44
|
|
7
|
+
* minutes of a `windows-latest` runner, and — because the run stayed
|
|
8
|
+
* `in_progress` — GitHub withheld the logs of the *required* check that had
|
|
9
|
+
* already failed on the same run, so the real merge blocker could not be
|
|
10
|
+
* diagnosed until the run was cancelled by hand. An unbounded job costs
|
|
11
|
+
* runner time and diagnosability at once.
|
|
12
|
+
*
|
|
13
|
+
* A ceiling only helps if it is bounded on both ends, so this gate fails on
|
|
14
|
+
* two shapes:
|
|
15
|
+
*
|
|
16
|
+
* - a job with no `timeout-minutes` at all (unbounded, 360-minute default);
|
|
17
|
+
* - a job whose `timeout-minutes` exceeds `MAX_TIMEOUT_MINUTES`, i.e. a
|
|
18
|
+
* number generous enough to be useless as a deadlock detector.
|
|
19
|
+
*
|
|
20
|
+
* `MIN_TIMEOUT_MINUTES` is the floor a value must clear to be plausible
|
|
21
|
+
* headroom over a job that takes a couple of minutes today; below it, a
|
|
22
|
+
* transient slow runner reds the build for no defect.
|
|
23
|
+
*
|
|
24
|
+
* Contract:
|
|
25
|
+
* - Scans the workflows directory (default `.github/workflows`, override
|
|
26
|
+
* with `--dir <path>`), parsing each `*.yml` / `*.yaml` with js-yaml and
|
|
27
|
+
* enumerating every `jobs.<id>` key.
|
|
28
|
+
* - Prints `<file> job <id> — <reason>` per violation, then a one-line
|
|
29
|
+
* summary even on a clean scan so the "ok" signal is visible in CI.
|
|
30
|
+
* - With `--json`: writes a structured envelope to stdout and skips the
|
|
31
|
+
* human summary.
|
|
32
|
+
* - Exit codes: 0 = every job bounded in range; 1 = at least one violation.
|
|
33
|
+
* A missing / empty workflows directory exits 0 (nothing to gate).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
import yaml from 'js-yaml';
|
|
40
|
+
import { runAsCli } from './lib/cli-utils.js';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Inclusive bounds every job's `timeout-minutes` must sit within.
|
|
44
|
+
*
|
|
45
|
+
* The ceiling is the load-bearing half — it is what turns a deadlock into a
|
|
46
|
+
* bounded loss instead of a six-hour one. The floor keeps a well-meaning
|
|
47
|
+
* `timeout-minutes: 2` from reddening the build on a slow runner.
|
|
48
|
+
*/
|
|
49
|
+
export const MIN_TIMEOUT_MINUTES = 10;
|
|
50
|
+
export const MAX_TIMEOUT_MINUTES = 30;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
|
|
54
|
+
* Returns absolute paths sorted for deterministic output. A missing directory
|
|
55
|
+
* yields an empty list.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} dir Absolute workflows directory.
|
|
58
|
+
* @param {typeof fs} [fsLike]
|
|
59
|
+
* @returns {string[]}
|
|
60
|
+
*/
|
|
61
|
+
export function listWorkflowFiles(dir, fsLike = fs) {
|
|
62
|
+
let entries;
|
|
63
|
+
try {
|
|
64
|
+
entries = fsLike.readdirSync(dir, { withFileTypes: true });
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
return entries
|
|
69
|
+
.filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
|
|
70
|
+
.map((e) => path.join(dir, e.name))
|
|
71
|
+
.sort();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Read the `jobs` mapping out of one workflow document.
|
|
76
|
+
*
|
|
77
|
+
* The single parse point for both the violation scan and the job count, so
|
|
78
|
+
* the two can never disagree about what a workflow's job set is.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} text The file contents.
|
|
81
|
+
* @param {(s: string) => unknown} parse YAML parser; injected in tests.
|
|
82
|
+
* @returns {{ jobs: Record<string, unknown> | null, error: Error | null }}
|
|
83
|
+
*/
|
|
84
|
+
function readJobs(text, parse) {
|
|
85
|
+
let doc;
|
|
86
|
+
try {
|
|
87
|
+
doc = parse(text);
|
|
88
|
+
} catch (err) {
|
|
89
|
+
return { jobs: null, error: err };
|
|
90
|
+
}
|
|
91
|
+
const jobs = doc && typeof doc === 'object' ? doc.jobs : null;
|
|
92
|
+
return {
|
|
93
|
+
jobs: jobs && typeof jobs === 'object' ? jobs : null,
|
|
94
|
+
error: null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Judge one job body's `timeout-minutes`, returning the violation shape or
|
|
100
|
+
* `null` when the job is bounded in range.
|
|
101
|
+
*
|
|
102
|
+
* Reusable-workflow calls (`jobs.<id>.uses:`) are exempt: `timeout-minutes`
|
|
103
|
+
* is not a valid key on a job that delegates to another workflow — the
|
|
104
|
+
* callee's own jobs carry their timeouts, and setting one here is a schema
|
|
105
|
+
* error rather than a fix.
|
|
106
|
+
*
|
|
107
|
+
* @param {unknown} body The `jobs.<id>` value.
|
|
108
|
+
* @returns {{ timeout: number | null, reason: string } | null}
|
|
109
|
+
*/
|
|
110
|
+
function judgeJobTimeout(body) {
|
|
111
|
+
if (!body || typeof body !== 'object') return null;
|
|
112
|
+
if (typeof body.uses === 'string') return null;
|
|
113
|
+
|
|
114
|
+
const timeout = body['timeout-minutes'];
|
|
115
|
+
if (timeout === undefined || timeout === null) {
|
|
116
|
+
return {
|
|
117
|
+
timeout: null,
|
|
118
|
+
reason: `no timeout-minutes — the job inherits GitHub's 360-minute default`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (typeof timeout !== 'number' || !Number.isFinite(timeout)) {
|
|
122
|
+
return {
|
|
123
|
+
timeout: null,
|
|
124
|
+
reason: `timeout-minutes is not a number (${JSON.stringify(timeout)})`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (timeout > MAX_TIMEOUT_MINUTES) {
|
|
128
|
+
return {
|
|
129
|
+
timeout,
|
|
130
|
+
reason: `timeout-minutes ${timeout} exceeds the ${MAX_TIMEOUT_MINUTES}-minute ceiling`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (timeout < MIN_TIMEOUT_MINUTES) {
|
|
134
|
+
return {
|
|
135
|
+
timeout,
|
|
136
|
+
reason: `timeout-minutes ${timeout} is below the ${MIN_TIMEOUT_MINUTES}-minute floor`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Pure helper: enumerate every `jobs.<id>` key in one workflow document and
|
|
144
|
+
* return the violations.
|
|
145
|
+
*
|
|
146
|
+
* An unparseable document is itself a violation: a workflow whose jobs cannot
|
|
147
|
+
* be enumerated cannot be certified as bounded, and failing open would let
|
|
148
|
+
* the very drift this gate exists to catch through.
|
|
149
|
+
*
|
|
150
|
+
* @param {string} file Relative file label used in violation rows.
|
|
151
|
+
* @param {string} text The file contents.
|
|
152
|
+
* @param {(s: string) => unknown} [parse] YAML parser; injected in tests.
|
|
153
|
+
* @returns {Array<{ file: string, job: string, timeout: number | null, reason: string }>}
|
|
154
|
+
*/
|
|
155
|
+
export function scanWorkflowText(file, text, parse = yaml.load) {
|
|
156
|
+
const { jobs, error } = readJobs(text, parse);
|
|
157
|
+
if (error) {
|
|
158
|
+
return [
|
|
159
|
+
{
|
|
160
|
+
file,
|
|
161
|
+
job: '(document)',
|
|
162
|
+
timeout: null,
|
|
163
|
+
reason: `workflow could not be parsed as YAML: ${error.message}`,
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
if (!jobs) return [];
|
|
168
|
+
|
|
169
|
+
const violations = [];
|
|
170
|
+
for (const [job, body] of Object.entries(jobs)) {
|
|
171
|
+
const verdict = judgeJobTimeout(body);
|
|
172
|
+
if (verdict) violations.push({ file, job, ...verdict });
|
|
173
|
+
}
|
|
174
|
+
return violations;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Pure helper: render the human-readable report. One line per violation
|
|
179
|
+
* followed by a one-line summary carrying a `(gate fail)` / `(ok)` marker.
|
|
180
|
+
*
|
|
181
|
+
* @param {Array<{ file: string, job: string, timeout: number | null, reason: string }>} violations
|
|
182
|
+
* @param {number} jobsScanned
|
|
183
|
+
* @returns {string}
|
|
184
|
+
*/
|
|
185
|
+
export function renderReport(violations, jobsScanned) {
|
|
186
|
+
const lines = violations.map((v) => `${v.file} job ${v.job} — ${v.reason}`);
|
|
187
|
+
const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
|
|
188
|
+
lines.push(
|
|
189
|
+
`[workflow-timeouts] jobs=${jobsScanned} violations=${violations.length} ${tag}`,
|
|
190
|
+
);
|
|
191
|
+
return lines.join('\n');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Count every `jobs.<id>` key in a parsed workflow document, including the
|
|
196
|
+
* reusable-workflow calls the violation scan exempts — the summary reports
|
|
197
|
+
* how many jobs were enumerated, not how many were eligible.
|
|
198
|
+
*
|
|
199
|
+
* @param {string} text
|
|
200
|
+
* @param {(s: string) => unknown} [parse]
|
|
201
|
+
* @returns {number}
|
|
202
|
+
*/
|
|
203
|
+
export function countJobs(text, parse = yaml.load) {
|
|
204
|
+
const { jobs } = readJobs(text, parse);
|
|
205
|
+
return jobs ? Object.keys(jobs).length : 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Top-level CLI entry. Exported so tests can drive the full pipeline against
|
|
210
|
+
* a fixture workflows directory without touching the repo's real workflows.
|
|
211
|
+
*
|
|
212
|
+
* @param {{
|
|
213
|
+
* argv?: string[],
|
|
214
|
+
* cwd?: string,
|
|
215
|
+
* stdout?: { write: (s: string) => void },
|
|
216
|
+
* stderr?: { write: (s: string) => void },
|
|
217
|
+
* }} [opts]
|
|
218
|
+
* @returns {Promise<number>} exit code: 0 = every job bounded; 1 = violation
|
|
219
|
+
*/
|
|
220
|
+
export async function runCli({
|
|
221
|
+
argv = process.argv.slice(2),
|
|
222
|
+
cwd = process.cwd(),
|
|
223
|
+
stdout = process.stdout,
|
|
224
|
+
stderr = process.stderr,
|
|
225
|
+
} = {}) {
|
|
226
|
+
const dirIdx = argv.indexOf('--dir');
|
|
227
|
+
const dir =
|
|
228
|
+
dirIdx !== -1 && argv[dirIdx + 1] && !argv[dirIdx + 1].startsWith('--')
|
|
229
|
+
? argv[dirIdx + 1]
|
|
230
|
+
: null;
|
|
231
|
+
const json = argv.includes('--json');
|
|
232
|
+
const resolvedDir = path.resolve(
|
|
233
|
+
cwd,
|
|
234
|
+
dir ?? path.join('.github', 'workflows'),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const files = listWorkflowFiles(resolvedDir);
|
|
238
|
+
const violations = [];
|
|
239
|
+
let jobsScanned = 0;
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
let text;
|
|
242
|
+
try {
|
|
243
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
244
|
+
} catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const label = path.relative(cwd, file);
|
|
248
|
+
jobsScanned += countJobs(text);
|
|
249
|
+
violations.push(...scanWorkflowText(label, text));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const exitCode = violations.length > 0 ? 1 : 0;
|
|
253
|
+
|
|
254
|
+
if (json) {
|
|
255
|
+
stdout.write(
|
|
256
|
+
`${JSON.stringify(
|
|
257
|
+
{
|
|
258
|
+
kind: 'workflow-timeouts-report',
|
|
259
|
+
dir: resolvedDir,
|
|
260
|
+
filesScanned: files.length,
|
|
261
|
+
jobsScanned,
|
|
262
|
+
minTimeoutMinutes: MIN_TIMEOUT_MINUTES,
|
|
263
|
+
maxTimeoutMinutes: MAX_TIMEOUT_MINUTES,
|
|
264
|
+
violations,
|
|
265
|
+
exitCode,
|
|
266
|
+
},
|
|
267
|
+
null,
|
|
268
|
+
2,
|
|
269
|
+
)}\n`,
|
|
270
|
+
);
|
|
271
|
+
} else {
|
|
272
|
+
if (files.length === 0) {
|
|
273
|
+
stderr.write(
|
|
274
|
+
`[workflow-timeouts] ⚠ no workflow files found under ${resolvedDir}\n`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
stdout.write(`${renderReport(violations, jobsScanned)}\n`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return exitCode;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function main() {
|
|
284
|
+
return runCli();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
runAsCli(import.meta.url, main, {
|
|
288
|
+
source: 'workflow-timeouts',
|
|
289
|
+
propagateExitCode: true,
|
|
290
|
+
errorPrefix: '[workflow-timeouts] ❌ Fatal error',
|
|
291
|
+
});
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Removes **test-reserved** per-Epic trees under `<repo>/temp/epic-<id>/` after
|
|
6
|
-
* test runs. Only Epic IDs in the band
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* test runs. Only Epic IDs in the band declared by `lib/reserved-test-ids.js`
|
|
7
|
+
* are deleted (999000–999999). Real Epic scratch (e.g. `temp/epic-1143/`) is
|
|
8
|
+
* never touched.
|
|
9
9
|
*
|
|
10
10
|
* Keeps sibling dirs like `temp/epic-runner-logs/`, loose files, and
|
|
11
11
|
* `temp/epic-<id>/` when `<id>` is outside that reserved band.
|
|
@@ -20,11 +20,13 @@
|
|
|
20
20
|
import { existsSync, readdirSync, rmSync } from 'node:fs';
|
|
21
21
|
import path from 'node:path';
|
|
22
22
|
import { runAsCli } from './lib/cli-utils.js';
|
|
23
|
+
import { isReservedTestId } from './lib/reserved-test-ids.js';
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const EPIC_TEMP_DIR = /^epic-(\d+)$/;
|
|
25
26
|
|
|
26
27
|
function isReservedTestEpicTempDirName(name) {
|
|
27
|
-
|
|
28
|
+
const match = EPIC_TEMP_DIR.exec(name);
|
|
29
|
+
return match !== null && isReservedTestId(Number(match[1]));
|
|
28
30
|
}
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -337,10 +337,9 @@ async function runBackstopMode(values, deps = {}) {
|
|
|
337
337
|
process.stderr.write(HELP);
|
|
338
338
|
throw new Error('[deliver-light] --backstop requires --story <id>');
|
|
339
339
|
}
|
|
340
|
-
const { result, nextCommand, exitCode, message } =
|
|
341
|
-
storyId
|
|
342
|
-
}
|
|
343
|
-
const extra = nextCommand === null ? {} : { nextCommand };
|
|
340
|
+
const { result, nextCommand, preservation, exitCode, message } =
|
|
341
|
+
await resolveFn({ storyId });
|
|
342
|
+
const extra = nextCommand === null ? {} : { nextCommand, preservation };
|
|
344
343
|
emit({ mode: 'backstop', storyId, ...result, ...extra }, values.pretty);
|
|
345
344
|
if (result.blocked) Logger.warn(message);
|
|
346
345
|
else Logger.info(message);
|
|
@@ -16,6 +16,14 @@
|
|
|
16
16
|
* `single-story-init.js` hard-errors on an already-closed Story. Before this
|
|
17
17
|
* surface, that Story had no automated way back.
|
|
18
18
|
*
|
|
19
|
+
* Every command it prints **resumes** — it re-enters the in-flight worker or
|
|
20
|
+
* close, or closes the branch that is already pushed. None of them re-dispatch
|
|
21
|
+
* the Story, and that is deliberate: since Story #4876 the close-and-land tail
|
|
22
|
+
* belongs to the dispatching orchestrator, so a worker returning without a
|
|
23
|
+
* terminal envelope is the expected shape rather than evidence the Story never
|
|
24
|
+
* ran. Answering it with a fresh dispatch re-runs `single-story-init.js`
|
|
25
|
+
* underneath live work and puts a second close on one PR.
|
|
26
|
+
*
|
|
19
27
|
* Usage:
|
|
20
28
|
* node .agents/scripts/deliver-recover.js --story <STORY_ID> [--cwd <main-repo>]
|
|
21
29
|
* [--json]
|
|
@@ -46,6 +54,11 @@ Probes a Story's live delivery state — labels, lease, branch, worktree, PR
|
|
|
46
54
|
state and checks — and prints the single next command that resumes it, with
|
|
47
55
|
the evidence it was derived from. Read-only: mutates nothing.
|
|
48
56
|
|
|
57
|
+
The command always resumes the existing worker or close (or closes an already
|
|
58
|
+
pushed branch); it never re-dispatches the Story, which would re-run init
|
|
59
|
+
underneath live work. A worker that returned no terminal envelope is expected —
|
|
60
|
+
the orchestrator owns the close-and-land tail.
|
|
61
|
+
|
|
49
62
|
Mid-flight shapes (executing-*/closing-*) get a stability re-probe after a
|
|
50
63
|
short settle window: matching shapes return the fresher verdict; diverging
|
|
51
64
|
shapes report \`in-transition\` (a live delivery process is mutating the
|
|
@@ -16,7 +16,11 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Usage:
|
|
18
18
|
* node diagnose-friction.js [--story <STORY_ID>] \
|
|
19
|
-
* [--epic <EPIC_ID>] --cmd <
|
|
19
|
+
* [--epic <EPIC_ID>] --cmd <cmd> <args...>
|
|
20
|
+
*
|
|
21
|
+
* `--cmd` consumes the remaining argv as separate words and spawns them with
|
|
22
|
+
* no shell. Quoting the whole command as one string is a usage error, not
|
|
23
|
+
* friction — it is refused loudly and writes no ledger row.
|
|
20
24
|
*
|
|
21
25
|
* Story/Epic resolution order:
|
|
22
26
|
* 1. CLI flags (--story, --epic).
|
|
@@ -108,6 +112,16 @@ function classifyFrictionCategory(errorOutput) {
|
|
|
108
112
|
*/
|
|
109
113
|
const INTERCEPTOR_TIMEOUT_SIGNAL = 'SIGTERM';
|
|
110
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A `maxBuffer` overflow presents identically to a timeout — `status: null`,
|
|
117
|
+
* `signal: 'SIGTERM'` — because Node kills the child the same way. The only
|
|
118
|
+
* discriminator is `result.error.code`, so a SIGTERM carrying this code is a
|
|
119
|
+
* buffer overflow and must never be reported as a timeout (Story #4915).
|
|
120
|
+
*
|
|
121
|
+
* @type {string}
|
|
122
|
+
*/
|
|
123
|
+
const OVERFLOW_ERROR_CODE = 'ENOBUFS';
|
|
124
|
+
|
|
111
125
|
/** Shell convention for "the process died by signal N": exit `128 + N`. */
|
|
112
126
|
const SIGNAL_EXIT_BASE = 128;
|
|
113
127
|
|
|
@@ -118,8 +132,10 @@ const SIGNAL_EXIT_BASE = 128;
|
|
|
118
132
|
* interceptor would report success for a command it just watched get killed
|
|
119
133
|
* (Story #4851).
|
|
120
134
|
*
|
|
121
|
-
* Which signal fired is the diagnostic value: SIGTERM points at the
|
|
122
|
-
* interceptor's own
|
|
135
|
+
* Which signal fired is the diagnostic value: SIGTERM points at one of the
|
|
136
|
+
* interceptor's own bounds, anything else at the host. A SIGTERM splits again
|
|
137
|
+
* on `error.code`: `ENOBUFS` means the output blew past `executionMaxBuffer`,
|
|
138
|
+
* anything else means `executionTimeoutMs` fired. Recording that plus the
|
|
123
139
|
* bound itself is what makes the row actionable to a consumer who cannot edit
|
|
124
140
|
* the materialized framework tree.
|
|
125
141
|
*
|
|
@@ -128,13 +144,17 @@ const SIGNAL_EXIT_BASE = 128;
|
|
|
128
144
|
* the file's per-file maintainability-delta headroom. The CLI contract is the
|
|
129
145
|
* seam the unit tests drive.
|
|
130
146
|
*
|
|
131
|
-
* @param {{signal: (string|null), error?: {message?: string}}}
|
|
132
|
-
* `spawnSync` result whose `status` is `null`.
|
|
133
|
-
* @param {
|
|
147
|
+
* @param {{signal: (string|null), error?: {message?: string, code?: string}}}
|
|
148
|
+
* result A `spawnSync` result whose `status` is `null`.
|
|
149
|
+
* @param {{executionTimeoutMs: number, executionMaxBuffer: number}} bounds The
|
|
150
|
+
* resolved interceptor bounds — the timeout in ms, the buffer in bytes.
|
|
134
151
|
* @returns {{category: string, remediation: string, details: object,
|
|
135
152
|
* preview: string, exitCode: number}}
|
|
136
153
|
*/
|
|
137
|
-
function describeAbnormalExit(
|
|
154
|
+
function describeAbnormalExit(
|
|
155
|
+
result,
|
|
156
|
+
{ executionTimeoutMs, executionMaxBuffer },
|
|
157
|
+
) {
|
|
138
158
|
const signal = typeof result.signal === 'string' ? result.signal : null;
|
|
139
159
|
if (signal === null) {
|
|
140
160
|
return {
|
|
@@ -150,15 +170,42 @@ function describeAbnormalExit(result, executionTimeoutMs) {
|
|
|
150
170
|
};
|
|
151
171
|
}
|
|
152
172
|
|
|
153
|
-
const
|
|
154
|
-
const
|
|
173
|
+
const sentByInterceptor = signal === INTERCEPTOR_TIMEOUT_SIGNAL;
|
|
174
|
+
const overflowed =
|
|
175
|
+
sentByInterceptor && result.error?.code === OVERFLOW_ERROR_CODE;
|
|
176
|
+
let killOrigin = 'external';
|
|
177
|
+
if (overflowed) killOrigin = 'buffer-overflow';
|
|
178
|
+
else if (sentByInterceptor) killOrigin = 'interceptor-timeout';
|
|
179
|
+
|
|
180
|
+
const shapes = {
|
|
181
|
+
'buffer-overflow': {
|
|
182
|
+
category: 'Tool Limitation',
|
|
183
|
+
remediation: ` - ${signal} was sent because the command's output overflowed the interceptor's executionMaxBuffer bound (${executionMaxBuffer} bytes / 10 MiB) — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire. Do NOT split the command into smaller steps: quieten or redirect its output, or raise the buffer bound.`,
|
|
184
|
+
extraDetails: { executionMaxBuffer },
|
|
185
|
+
},
|
|
186
|
+
'interceptor-timeout': {
|
|
187
|
+
category: 'Execution Timeout',
|
|
188
|
+
remediation: ` - ${signal} matches the interceptor's own executionTimeoutMs bound (${executionTimeoutMs}ms), so the command was almost certainly cut off rather than broken. Split it into smaller steps, or raise the bound.`,
|
|
189
|
+
extraDetails: {},
|
|
190
|
+
},
|
|
191
|
+
external: {
|
|
192
|
+
category: 'Execution Killed',
|
|
193
|
+
remediation: ` - ${signal} originated outside the interceptor — the executionTimeoutMs bound (${executionTimeoutMs}ms) did not fire, so suspect an OOM kill or a hard kill from the host. Reduce the command's memory footprint or give the host more headroom.`,
|
|
194
|
+
extraDetails: {},
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const shape = shapes[killOrigin];
|
|
155
199
|
const signum = osConstants.signals[signal];
|
|
156
200
|
return {
|
|
157
|
-
category:
|
|
158
|
-
remediation:
|
|
159
|
-
|
|
160
|
-
:
|
|
161
|
-
|
|
201
|
+
category: shape.category,
|
|
202
|
+
remediation: shape.remediation,
|
|
203
|
+
details: {
|
|
204
|
+
killedBySignal: signal,
|
|
205
|
+
killOrigin,
|
|
206
|
+
executionTimeoutMs,
|
|
207
|
+
...shape.extraDetails,
|
|
208
|
+
},
|
|
162
209
|
preview: `Command terminated by signal ${signal} (${killOrigin}); executionTimeoutMs=${executionTimeoutMs}.`,
|
|
163
210
|
exitCode: Number.isInteger(signum) ? SIGNAL_EXIT_BASE + signum : 1,
|
|
164
211
|
};
|
|
@@ -221,7 +268,23 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
221
268
|
|
|
222
269
|
if (cmdArgs.length === 0) {
|
|
223
270
|
throw new Error(
|
|
224
|
-
'Usage: node diagnose-friction.js [--story <STORY_ID>] [--epic <EPIC_ID>] --cmd <
|
|
271
|
+
'Usage: node diagnose-friction.js [--story <STORY_ID>] [--epic <EPIC_ID>] --cmd <cmd> <args...>',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Story #4915 — the interceptor spawns `cmdArgs[0]` directly, with no shell.
|
|
276
|
+
// A single argument containing whitespace therefore names an executable that
|
|
277
|
+
// cannot exist, and the resulting ENOENT is a usage error in the
|
|
278
|
+
// interceptor's OWN invocation, not friction in the wrapped command. It must
|
|
279
|
+
// be reported as one and MUST NOT reach the ledger — otherwise the real
|
|
280
|
+
// friction is discarded and the roll-up eventually auto-files a framework-gap
|
|
281
|
+
// ticket about the framework's own instrumentation being misused. The
|
|
282
|
+
// discriminator is this argv shape, never the ENOENT result: a correctly
|
|
283
|
+
// split command whose binary is genuinely absent yields the identical
|
|
284
|
+
// `spawnSync` result and stays real friction.
|
|
285
|
+
if (cmdArgs.length === 1 && /\s/.test(cmdArgs[0])) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
`Usage: --cmd takes the command as separate argv words, not one quoted string. Received a single quoted argument: "${cmdArgs[0]}". Drop the quotes so each word is its own argv entry — \`--cmd ${cmdArgs[0]}\`. No friction signal was recorded.`,
|
|
225
288
|
);
|
|
226
289
|
}
|
|
227
290
|
|
|
@@ -251,7 +314,10 @@ export async function main(args = process.argv.slice(2)) {
|
|
|
251
314
|
// `result.signal`, not in the status.
|
|
252
315
|
const abnormal =
|
|
253
316
|
result.status === null
|
|
254
|
-
? describeAbnormalExit(result,
|
|
317
|
+
? describeAbnormalExit(result, {
|
|
318
|
+
executionTimeoutMs,
|
|
319
|
+
executionMaxBuffer,
|
|
320
|
+
})
|
|
255
321
|
: null;
|
|
256
322
|
// With both streams empty an abnormal termination names its signal; the
|
|
257
323
|
// `Unknown exit code` fallback is therefore reachable only with a real
|
|
@@ -343,15 +409,15 @@ runAsCli(import.meta.url, main, {
|
|
|
343
409
|
source: 'DiagnoseFriction',
|
|
344
410
|
usage: {
|
|
345
411
|
invocation:
|
|
346
|
-
'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <
|
|
412
|
+
'node .agents/scripts/diagnose-friction.js [--story <id>] [--epic <id>] --cmd <cmd> <args...>',
|
|
347
413
|
summary:
|
|
348
414
|
'Run a command through the diagnostic interceptor: stream its output, then append a local friction signal describing the failure. Never posts to the ticket.',
|
|
349
415
|
flags: [
|
|
350
416
|
['--story <id>', 'Story the friction belongs to.'],
|
|
351
417
|
['--epic <id>', 'Epic the friction belongs to.'],
|
|
352
418
|
[
|
|
353
|
-
'--cmd <
|
|
354
|
-
'The command to execute; everything after it is the argv (required).',
|
|
419
|
+
'--cmd <cmd> <args...>',
|
|
420
|
+
'The command to execute; everything after it is the argv, as separate words — never one quoted string (required).',
|
|
355
421
|
],
|
|
356
422
|
],
|
|
357
423
|
},
|