dorfl 0.5.1 → 0.7.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/dist/apply-merge-action.js +1 -1
- package/dist/apply-merge-action.js.map +1 -1
- package/dist/complete.d.ts +1 -1
- package/dist/complete.d.ts.map +1 -1
- package/dist/complete.js +14 -0
- package/dist/complete.js.map +1 -1
- package/dist/integration-core.d.ts +24 -1
- package/dist/integration-core.d.ts.map +1 -1
- package/dist/integration-core.js +120 -4
- package/dist/integration-core.js.map +1 -1
- package/dist/protocol/CLAIM-PROTOCOL.md +20 -0
- package/dist/protocol/WORK-CONTRACT.md +5 -1
- package/dist/run.d.ts.map +1 -1
- package/dist/run.js +6 -0
- package/dist/run.js.map +1 -1
- package/dist/sidecar-guard.d.ts +72 -0
- package/dist/sidecar-guard.d.ts.map +1 -0
- package/dist/sidecar-guard.js +118 -0
- package/dist/sidecar-guard.js.map +1 -0
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +20 -0
- package/dist/skills/setup/protocol/WORK-CONTRACT.md +5 -1
- package/dist/tasking.d.ts.map +1 -1
- package/dist/tasking.js +31 -0
- package/dist/tasking.js.map +1 -1
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -1
- package/dist/verify.js +55 -3
- package/dist/verify.js.map +1 -1
- package/package.json +1 -1
- package/src/apply-merge-action.ts +1 -1
- package/src/complete.ts +15 -0
- package/src/integration-core.ts +146 -4
- package/src/run.ts +6 -0
- package/src/sidecar-guard.ts +142 -0
- package/src/tasking.ts +33 -0
- package/src/verify.ts +73 -3
package/src/integration-core.ts
CHANGED
|
@@ -36,6 +36,47 @@ import {git, run, runAsync, type RunResult} from './git.js';
|
|
|
36
36
|
import {realSleep, type Sleep} from './retry-backoff.js';
|
|
37
37
|
import {workBranchRef} from './slug-namespace.js';
|
|
38
38
|
import {isAncestor} from './gc.js';
|
|
39
|
+
import {
|
|
40
|
+
detectColocatedSidecars,
|
|
41
|
+
formatSidecarGuardReason,
|
|
42
|
+
} from './sidecar-guard.js';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the human-facing CONTEXT tail appended to an `acceptance gate failed`
|
|
46
|
+
* reason — the load-bearing fix for the opaque `acceptance gate failed (exit N)`
|
|
47
|
+
* bounce message (a maintainer could not tell WHICH command failed or WHY without
|
|
48
|
+
* re-running the whole gate). Given the failed command + its output tail (both
|
|
49
|
+
* from {@link RunVerifyResult}), it produces a compact, deterministic block:
|
|
50
|
+
*
|
|
51
|
+
* - names the FAILED command (`the failing step was: \`<cmd>\``), so a
|
|
52
|
+
* multi-command gate (`build && test && format:check`) points at the culprit;
|
|
53
|
+
* - quotes the last lines of that command's output (the ACTUAL error, e.g.
|
|
54
|
+
* "no changesets were found"), so the answer surface carries real signal;
|
|
55
|
+
* - when neither is known (an older result / a prepare failure) it returns the
|
|
56
|
+
* empty string — the caller's base reason is unchanged (graceful degrade).
|
|
57
|
+
*
|
|
58
|
+
* PURE + seam-free (string in, string out) so it is unit-testable and reused by
|
|
59
|
+
* every gate-failure site (front gate, rebased-tip fresh gate, committed
|
|
60
|
+
* recovery). The output is bounded by {@link RunVerifyResult.outputTail}'s own
|
|
61
|
+
* cap, so it can never bloat the surfaced sidecar.
|
|
62
|
+
*/
|
|
63
|
+
export function formatGateFailureContext(params: {
|
|
64
|
+
failedCommand?: string;
|
|
65
|
+
outputTail?: string;
|
|
66
|
+
}): string {
|
|
67
|
+
const {failedCommand, outputTail} = params;
|
|
68
|
+
const parts: string[] = [];
|
|
69
|
+
if (failedCommand !== undefined && failedCommand.trim() !== '') {
|
|
70
|
+
parts.push(`the failing step was: \`${failedCommand.trim()}\``);
|
|
71
|
+
}
|
|
72
|
+
if (outputTail !== undefined && outputTail.trim() !== '') {
|
|
73
|
+
parts.push(`its last output was:\n\n${outputTail.trimEnd()}`);
|
|
74
|
+
}
|
|
75
|
+
if (parts.length === 0) {
|
|
76
|
+
return '';
|
|
77
|
+
}
|
|
78
|
+
return ` — ${parts.join('; ')}`;
|
|
79
|
+
}
|
|
39
80
|
|
|
40
81
|
/**
|
|
41
82
|
* **The shared gate→integrate BACK-HALF** of the per-item pipeline, extracted out
|
|
@@ -92,6 +133,7 @@ export type IntegrationCoreOutcome =
|
|
|
92
133
|
| 'review-blocked' // Gate 2 (PR/code review) returned `block` (or exhausted rounds)
|
|
93
134
|
| 'review-unparseable' // Gate 2 ran but its verdict JSON could not be parsed (malformed output) — work-preserving route, transient-infra cause (NOT a reviewer block)
|
|
94
135
|
| 'rebase-conflict' // rebase onto arbiter/main conflicted (aborted; human resolves)
|
|
136
|
+
| 'sidecar-violation' // a co-located <slug>/ sidecar sits beside a flowing task/spec item (WORK-CONTRACT rule 8) — HARD BLOCK before the durable move
|
|
95
137
|
| 'invariant-violation' // one-slug-one-folder would break (slug in two folders on the arbiter)
|
|
96
138
|
| 'already-integrated'; // committed-recovery: the kept tip is already on <arbiter>/main (clean no-op)
|
|
97
139
|
|
|
@@ -854,7 +896,15 @@ export async function performIntegration(
|
|
|
854
896
|
// reason on the lock entry, no `in-progress/ → needs-attention/` folder
|
|
855
897
|
// move) plus saving the agent's uncommitted work as a wip commit. No
|
|
856
898
|
// partial state.
|
|
857
|
-
|
|
899
|
+
// Enrich the surfaced reason with WHICH gate command failed + the tail of
|
|
900
|
+
// its output (the actual error), so the needs-attention question is
|
|
901
|
+
// actionable instead of a bare exit code.
|
|
902
|
+
const reason =
|
|
903
|
+
`acceptance gate failed (exit ${gate.exitCode})` +
|
|
904
|
+
formatGateFailureContext({
|
|
905
|
+
failedCommand: gate.failedCommand,
|
|
906
|
+
outputTail: gate.outputTail,
|
|
907
|
+
});
|
|
858
908
|
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
859
909
|
cwd,
|
|
860
910
|
slug,
|
|
@@ -933,6 +983,61 @@ export async function performIntegration(
|
|
|
933
983
|
});
|
|
934
984
|
}
|
|
935
985
|
|
|
986
|
+
// 1c. CO-LOCATED SIDECAR GUARD (WORK-CONTRACT.md rule 8). A `<slug>/` asset
|
|
987
|
+
// sidecar co-located with a FLOWING task/spec item strands on the
|
|
988
|
+
// `ready → done` / `ready → tasked` `git mv` (the `<slug>.md` moves, the
|
|
989
|
+
// `<slug>/` folder is left behind — one item split across two status folders,
|
|
990
|
+
// the SAME one-slug-one-folder invariant `ledger-lint`/the integration core
|
|
991
|
+
// enforce). So this is a HARD BLOCK at LAND, BEFORE the durable move: a
|
|
992
|
+
// detected sidecar routes the item to needs-attention via the SAME
|
|
993
|
+
// `applyNeedsAttentionTransition` seam the red gate / review block use, with
|
|
994
|
+
// an ACTIONABLE relocate-to-`docs/spikes/<slug>/` reason. A `notes/*` sidecar
|
|
995
|
+
// is NOT scanned (notes do not flow), and the `work/questions/*`
|
|
996
|
+
// status-mechanism file is not an item sidecar — so neither false-positives.
|
|
997
|
+
// Runs for BOTH the build and the tasking (`lifecycle`) transitions: either
|
|
998
|
+
// way a flowing item is about to be `git mv`'d and would strand a sidecar.
|
|
999
|
+
// ROUTING: the BUILD path routes here (the task lock the `applyNeedsAttention
|
|
1000
|
+
// Transition` seam keys on — `task:<slug>` — is the one `claim` holds); the
|
|
1001
|
+
// TASKING (`lifecycle`) path does its OWN spec-lock routing in `tasking.ts`
|
|
1002
|
+
// from `reviewBlockReason` (exactly as the review-blocked tasking path does),
|
|
1003
|
+
// so here it only RETURNS the reason without touching a `task:` lock that a
|
|
1004
|
+
// tasking run never held.
|
|
1005
|
+
const colocatedSidecars = detectColocatedSidecars(cwd, slug);
|
|
1006
|
+
if (colocatedSidecars.length > 0) {
|
|
1007
|
+
const reason = formatSidecarGuardReason(colocatedSidecars);
|
|
1008
|
+
if (lifecycle) {
|
|
1009
|
+
return {
|
|
1010
|
+
outcome: 'sidecar-violation',
|
|
1011
|
+
routedToNeedsAttention: false,
|
|
1012
|
+
branch,
|
|
1013
|
+
reason:
|
|
1014
|
+
`Co-located sidecar detected for '${slug}'; not completing the ` +
|
|
1015
|
+
`transition. ${reason}`,
|
|
1016
|
+
reviewBlockReason: reason,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1020
|
+
cwd,
|
|
1021
|
+
slug,
|
|
1022
|
+
reason,
|
|
1023
|
+
arbiter: input.surfaceArbiter,
|
|
1024
|
+
env,
|
|
1025
|
+
note,
|
|
1026
|
+
});
|
|
1027
|
+
return {
|
|
1028
|
+
outcome: 'sidecar-violation',
|
|
1029
|
+
routedToNeedsAttention: routed.moved,
|
|
1030
|
+
branch,
|
|
1031
|
+
reason: routed.moved
|
|
1032
|
+
? `Co-located task/spec sidecar detected for '${slug}'; marked it stuck ` +
|
|
1033
|
+
`on its per-item lock (a flowing item's <slug>/ sidecar strands on ` +
|
|
1034
|
+
`the durable move). ${reason}`
|
|
1035
|
+
: `Co-located task/spec sidecar detected for '${slug}'; not completing ` +
|
|
1036
|
+
`it. ${reason}`,
|
|
1037
|
+
reviewBlockReason: reason,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
|
|
936
1041
|
// Read the title now, BEFORE the move, for the default commit summary AND the
|
|
937
1042
|
// synthesised propose-mode PR TITLE (the source file is about to be git-mv'd
|
|
938
1043
|
// away). The PR title is a SINGLE, capped line built runner-side from the
|
|
@@ -1314,10 +1419,21 @@ export async function performIntegration(
|
|
|
1314
1419
|
gated.kind === 'prepare'
|
|
1315
1420
|
? `Env-prep (prepare) failed (exit ${gated.exitCode})`
|
|
1316
1421
|
: `Acceptance gate failed (exit ${gated.exitCode})`;
|
|
1422
|
+
// The gate-failure CONTEXT (which command failed + its output tail) is
|
|
1423
|
+
// meaningful only for a `verify` failure; a prepare failure names its
|
|
1424
|
+
// single command already. Enrich the surfaced reason so the question is
|
|
1425
|
+
// actionable rather than an opaque exit code.
|
|
1426
|
+
const context =
|
|
1427
|
+
gated.kind === 'prepare'
|
|
1428
|
+
? ''
|
|
1429
|
+
: formatGateFailureContext({
|
|
1430
|
+
failedCommand: gated.failedCommand,
|
|
1431
|
+
outputTail: gated.outputTail,
|
|
1432
|
+
});
|
|
1317
1433
|
const reason =
|
|
1318
1434
|
gated.kind === 'prepare'
|
|
1319
1435
|
? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
|
|
1320
|
-
: `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
|
|
1436
|
+
: `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip${context}`;
|
|
1321
1437
|
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1322
1438
|
cwd,
|
|
1323
1439
|
slug,
|
|
@@ -1898,10 +2014,17 @@ async function recoverAlreadyCommitted(params: {
|
|
|
1898
2014
|
gated.kind === 'prepare'
|
|
1899
2015
|
? `Env-prep (prepare) failed (exit ${gated.exitCode})`
|
|
1900
2016
|
: `Acceptance gate failed (exit ${gated.exitCode})`;
|
|
2017
|
+
const context =
|
|
2018
|
+
gated.kind === 'prepare'
|
|
2019
|
+
? ''
|
|
2020
|
+
: formatGateFailureContext({
|
|
2021
|
+
failedCommand: gated.failedCommand,
|
|
2022
|
+
outputTail: gated.outputTail,
|
|
2023
|
+
});
|
|
1901
2024
|
const reason =
|
|
1902
2025
|
gated.kind === 'prepare'
|
|
1903
2026
|
? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
|
|
1904
|
-
: `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
|
|
2027
|
+
: `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip${context}`;
|
|
1905
2028
|
const routed = await ledgerWrite.applyNeedsAttentionTransition({
|
|
1906
2029
|
cwd,
|
|
1907
2030
|
slug,
|
|
@@ -3055,6 +3178,19 @@ interface FreshGateResult {
|
|
|
3055
3178
|
kind?: 'prepare' | 'verify';
|
|
3056
3179
|
/** The non-zero exit code of the failing step (when `!passed`). */
|
|
3057
3180
|
exitCode?: number;
|
|
3181
|
+
/**
|
|
3182
|
+
* The EXACT gate command that failed (`verify` kind only) — threaded from
|
|
3183
|
+
* {@link RunVerifyResult.failedCommand} so the surfaced reason names WHICH step
|
|
3184
|
+
* of a multi-command gate failed. Absent for a `prepare` failure (the prepare
|
|
3185
|
+
* command is single + already named).
|
|
3186
|
+
*/
|
|
3187
|
+
failedCommand?: string;
|
|
3188
|
+
/**
|
|
3189
|
+
* The TAIL of the failed gate command's output (`verify` kind only) — threaded
|
|
3190
|
+
* from {@link RunVerifyResult.outputTail} so the surfaced question carries the
|
|
3191
|
+
* actual error text.
|
|
3192
|
+
*/
|
|
3193
|
+
outputTail?: string;
|
|
3058
3194
|
/**
|
|
3059
3195
|
* The Gate-2 REVIEW outcome, present ONLY when a review gate was supplied to the
|
|
3060
3196
|
* fresh gate AND `verify` passed (so the review ran AFTER it on the rebased tip).
|
|
@@ -3144,7 +3280,13 @@ async function runFreshWorktreeGate(params: {
|
|
|
3144
3280
|
env,
|
|
3145
3281
|
});
|
|
3146
3282
|
if (!gate.passed) {
|
|
3147
|
-
return {
|
|
3283
|
+
return {
|
|
3284
|
+
passed: false,
|
|
3285
|
+
kind: 'verify',
|
|
3286
|
+
exitCode: gate.exitCode,
|
|
3287
|
+
failedCommand: gate.failedCommand,
|
|
3288
|
+
outputTail: gate.outputTail,
|
|
3289
|
+
};
|
|
3148
3290
|
}
|
|
3149
3291
|
// GATE-2 REVIEW on the rebased tip, AFTER the green verify (verify-then-review
|
|
3150
3292
|
// on the SAME merged tree). Runs while the worktree is still live (the review
|
package/src/run.ts
CHANGED
|
@@ -1059,8 +1059,14 @@ async function runOneItem(
|
|
|
1059
1059
|
core.outcome === 'prepare-failed' ||
|
|
1060
1060
|
core.outcome === 'review-blocked' ||
|
|
1061
1061
|
core.outcome === 'rebase-conflict' ||
|
|
1062
|
+
core.outcome === 'sidecar-violation' ||
|
|
1062
1063
|
core.outcome === 'invariant-violation'
|
|
1063
1064
|
) {
|
|
1065
|
+
// `sidecar-violation`: a co-located `<slug>/` sidecar sits beside a FLOWING
|
|
1066
|
+
// task/spec item (WORK-CONTRACT rule 8) — a HARD BLOCK before the durable
|
|
1067
|
+
// move (it would strand on `ready → done`). The core routed it to
|
|
1068
|
+
// needs-attention with an actionable relocate-to-`docs/spikes/<slug>/`
|
|
1069
|
+
// reason; the least-supervised caller MUST NOT fall through to success.
|
|
1064
1070
|
// `prepare-failed`: the env-prep (install) step was red, so the env could
|
|
1065
1071
|
// not be made ready and `verify` was NOT run — distinct from a `tests-failed`
|
|
1066
1072
|
// red gate. Route it to needs-attention like the others (a human fixes the
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {existsSync, statSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
workFolderName,
|
|
5
|
+
workFolderPath,
|
|
6
|
+
type WorkFolderKey,
|
|
7
|
+
} from './work-layout.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The **co-located task/spec sidecar GUARD** (WORK-CONTRACT.md rule 8, the
|
|
11
|
+
* `notes/*`-only scoping).
|
|
12
|
+
*
|
|
13
|
+
* WHAT IT ENFORCES. A `<slug>/` asset sidecar folder co-located with a work item
|
|
14
|
+
* is ALLOWED for `notes/*` ONLY (`ideas`/`observations`/`findings` — they do NOT
|
|
15
|
+
* flow; a note leaves by deletion, so its sidecar never moves). It is FORBIDDEN
|
|
16
|
+
* for a `tasks/*` or `specs/*` item, because those regimes FLOW through status
|
|
17
|
+
* folders (`tasks/ready → tasks/done`, `specs/ready → specs/tasked`, …): a
|
|
18
|
+
* co-located sidecar shares the item's lifecycle and must be `git mv`'d in
|
|
19
|
+
* lockstep on every transition, and in practice gets STRANDED — the `<slug>.md`
|
|
20
|
+
* moves to the new status folder while the `<slug>/` sidecar is left behind in
|
|
21
|
+
* the old one, splitting ONE item across TWO status folders (a
|
|
22
|
+
* one-slug-one-folder violation, the SAME invariant `ledger-lint.ts` reads and
|
|
23
|
+
* the integration core enforces). A task's/spec's durable companion artifacts
|
|
24
|
+
* belong in the STABLE, non-flowing `docs/spikes/<slug>/` home (referenced by
|
|
25
|
+
* path from the `<slug>.md`), NOT a co-located sidecar.
|
|
26
|
+
*
|
|
27
|
+
* WHERE IT RUNS. This is the DETECTOR half; the integration core (`integration-core.ts`)
|
|
28
|
+
* wires it as a HARD BLOCK at LAND, BEFORE the durable `git mv` — a detected
|
|
29
|
+
* sidecar routes the item to needs-attention with {@link formatSidecarGuardReason},
|
|
30
|
+
* consistent with the status=folder / one-item-one-location contract the stranding
|
|
31
|
+
* violates. Fix = `git mv` the sidecar contents to `docs/spikes/<slug>/` + a
|
|
32
|
+
* reference edit in the `<slug>.md`.
|
|
33
|
+
*
|
|
34
|
+
* NO FALSE POSITIVES on: (a) the `work/questions/<type>-<slug>.md` needs-attention
|
|
35
|
+
* file — it is a tooling-owned STATUS-MECHANISM file, NOT scanned here (only
|
|
36
|
+
* `tasks/*` + `specs/*` FLOWING folders are); (b) a legitimate `notes/*` sidecar —
|
|
37
|
+
* the note buckets are deliberately EXCLUDED from the scan set; (c) a
|
|
38
|
+
* `docs/spikes/<slug>/` outside `work/` — this only ever looks INSIDE the FLOWING
|
|
39
|
+
* `work/` status folders.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The FLOWING status folders a `tasks/*` / `specs/*` item moves through — the ONLY
|
|
44
|
+
* folders scanned for an illegal co-located sidecar. Deliberately EXCLUDES the
|
|
45
|
+
* `notes/*` capture buckets (`ideas`/`observations`/`findings`, which legitimately
|
|
46
|
+
* MAY carry a sidecar) and the top-level `questions`/`protocol` surfaces (neither
|
|
47
|
+
* holds a flowing work item). A sidecar under any of THESE is a rule-8 violation
|
|
48
|
+
* because the item it sits beside will be `git mv`'d to another status folder and
|
|
49
|
+
* strand it.
|
|
50
|
+
*/
|
|
51
|
+
export const SIDECAR_GUARD_FLOWING_FOLDERS = [
|
|
52
|
+
'tasks-backlog',
|
|
53
|
+
'tasks-ready',
|
|
54
|
+
'done',
|
|
55
|
+
'cancelled',
|
|
56
|
+
'specs-proposed',
|
|
57
|
+
'specs-ready',
|
|
58
|
+
'specs-tasked',
|
|
59
|
+
'specs-dropped',
|
|
60
|
+
] as const satisfies readonly WorkFolderKey[];
|
|
61
|
+
|
|
62
|
+
/** One illegal co-located `<slug>/` sidecar found beside a flowing task/spec item. */
|
|
63
|
+
export interface ColocatedSidecar {
|
|
64
|
+
/** The flowing status folder the sidecar was found in. */
|
|
65
|
+
folder: WorkFolderKey;
|
|
66
|
+
/** The slug of the offending `<slug>/` sidecar directory. */
|
|
67
|
+
slug: string;
|
|
68
|
+
/** The repo-relative path of the sidecar directory (`work/<folder>/<slug>/`). */
|
|
69
|
+
dirRel: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Does `<dir>/<name>` exist AND is it a directory? (a sidecar is a folder). */
|
|
73
|
+
function isDir(dir: string, name: string): boolean {
|
|
74
|
+
try {
|
|
75
|
+
return statSync(join(dir, name)).isDirectory();
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Detect a co-located `<slug>/` asset sidecar directory sitting beside the
|
|
83
|
+
* `<slug>.md` of a FLOWING task/spec item, in the given `cwd`'s working tree.
|
|
84
|
+
* Scans ONLY {@link SIDECAR_GUARD_FLOWING_FOLDERS} for a `<slug>/` DIRECTORY whose
|
|
85
|
+
* sibling `<slug>.md` FILE is present (both must be there: a lone `<slug>/`
|
|
86
|
+
* directory with no item file is not this item's sidecar). Returns every offender
|
|
87
|
+
* found (typically at most one, for the item being landed), or `[]` when clean.
|
|
88
|
+
*
|
|
89
|
+
* PURE-ish: reads the filesystem, no writes, no throws. The integration core
|
|
90
|
+
* passes the specific `slug` being landed so the block is scoped to THAT item.
|
|
91
|
+
*/
|
|
92
|
+
export function detectColocatedSidecars(
|
|
93
|
+
cwd: string,
|
|
94
|
+
slug: string,
|
|
95
|
+
): ColocatedSidecar[] {
|
|
96
|
+
const found: ColocatedSidecar[] = [];
|
|
97
|
+
for (const folder of SIDECAR_GUARD_FLOWING_FOLDERS) {
|
|
98
|
+
const dir = workFolderPath(cwd, folder);
|
|
99
|
+
// The item file `<slug>.md` AND the sidecar dir `<slug>/` must BOTH be
|
|
100
|
+
// present for this to be the item's stranded-able sidecar. A stray `<slug>/`
|
|
101
|
+
// with no `<slug>.md` in the same folder is not an item's sidecar.
|
|
102
|
+
if (!existsSync(join(dir, `${slug}.md`))) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (isDir(dir, slug)) {
|
|
106
|
+
found.push({
|
|
107
|
+
folder,
|
|
108
|
+
slug,
|
|
109
|
+
dirRel: `work/${workFolderName(folder)}/${slug}/`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Format the ACTIONABLE needs-attention reason for a detected co-located sidecar
|
|
118
|
+
* (the message the LAND-time hard block surfaces VERBATIM). Names the offending
|
|
119
|
+
* path, the correct destination, and the exact fix — a `git mv` to
|
|
120
|
+
* `docs/spikes/<slug>/` plus a reference edit.
|
|
121
|
+
*/
|
|
122
|
+
export function formatSidecarGuardReason(
|
|
123
|
+
sidecars: readonly ColocatedSidecar[],
|
|
124
|
+
): string {
|
|
125
|
+
if (sidecars.length === 0) {
|
|
126
|
+
return '';
|
|
127
|
+
}
|
|
128
|
+
const lines = [
|
|
129
|
+
'task/spec artifacts belong in docs/spikes/<slug>/, not a co-located ' +
|
|
130
|
+
'work/tasks|specs/<slug>/ sidecar; only notes/* may carry a sidecar ' +
|
|
131
|
+
'(WORK-CONTRACT rule 8) — relocate + reference by path:',
|
|
132
|
+
];
|
|
133
|
+
for (const s of sidecars) {
|
|
134
|
+
lines.push(
|
|
135
|
+
` - ${s.dirRel}: git mv its contents to docs/spikes/${s.slug}/ ` +
|
|
136
|
+
`(a STABLE, non-flowing home), then reference them by that path from ` +
|
|
137
|
+
`work/${workFolderName(s.folder)}/${s.slug}.md (a flowing item's ` +
|
|
138
|
+
`co-located sidecar strands on the ready→done move).`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
return lines.join('\n');
|
|
142
|
+
}
|
package/src/tasking.ts
CHANGED
|
@@ -758,6 +758,39 @@ export async function performTask(
|
|
|
758
758
|
`marked the per-item lock stuck (needs attention; no tasks landed).`,
|
|
759
759
|
};
|
|
760
760
|
}
|
|
761
|
+
if (core.outcome === 'sidecar-violation') {
|
|
762
|
+
// A co-located `<slug>/` asset sidecar sits beside the FLOWING spec item
|
|
763
|
+
// (WORK-CONTRACT.md rule 8): the tasking transition would `git mv`
|
|
764
|
+
// `specs/ready → specs/tasked` and STRAND the sidecar. The core HARD-BLOCKED
|
|
765
|
+
// before the stage/integrate; route the held spec to needs-attention through
|
|
766
|
+
// the SAME `spec:<slug>` lock-release seam the block path uses (no tasks land),
|
|
767
|
+
// carrying the actionable relocate-to-`docs/spikes/<slug>/` reason.
|
|
768
|
+
const reason =
|
|
769
|
+
`The spec '${slug}' carries a co-located asset sidecar: ` +
|
|
770
|
+
`${core.reviewBlockReason ?? core.reason ?? ''}`;
|
|
771
|
+
const routed = await lock.release({
|
|
772
|
+
slug,
|
|
773
|
+
cwd,
|
|
774
|
+
arbiter,
|
|
775
|
+
lockedBlob,
|
|
776
|
+
routeToNeedsAttention: {reason},
|
|
777
|
+
env,
|
|
778
|
+
note,
|
|
779
|
+
});
|
|
780
|
+
if (routed.outcome !== 'released') {
|
|
781
|
+
return releaseFailureToResult(routed, slug);
|
|
782
|
+
}
|
|
783
|
+
note(reason);
|
|
784
|
+
return {
|
|
785
|
+
exitCode: 1,
|
|
786
|
+
outcome: 'needs-attention',
|
|
787
|
+
slug,
|
|
788
|
+
message:
|
|
789
|
+
`The spec '${slug}' carries a co-located asset sidecar (WORK-CONTRACT ` +
|
|
790
|
+
`rule 8); marked the per-item lock stuck (needs attention; no tasks ` +
|
|
791
|
+
`landed; relocate it to docs/spikes/${slug}/ and reference by path).`,
|
|
792
|
+
};
|
|
793
|
+
}
|
|
761
794
|
if (core.outcome === 'review-unparseable') {
|
|
762
795
|
// The task-set acceptance gate RAN but its verdict was UNPARSEABLE (malformed
|
|
763
796
|
// JSON). Route the held spec to needs-attention through the SAME lock-release
|
package/src/verify.ts
CHANGED
|
@@ -99,6 +99,22 @@ export interface RunVerifyResult {
|
|
|
99
99
|
commands: string[];
|
|
100
100
|
/** Whether the gate passed (exitCode === 0). */
|
|
101
101
|
passed: boolean;
|
|
102
|
+
/**
|
|
103
|
+
* The EXACT command that failed (the first non-zero exit — `&&`-short-circuit
|
|
104
|
+
* semantics), verbatim from the resolved gate list. Present ONLY on a failing
|
|
105
|
+
* result with a configured gate. This is the load-bearing context a bare `exit
|
|
106
|
+
* N` throws away: in a multi-command gate (`build && test && format:check`) it
|
|
107
|
+
* tells the human WHICH step failed without re-running the whole gate.
|
|
108
|
+
*/
|
|
109
|
+
failedCommand?: string;
|
|
110
|
+
/**
|
|
111
|
+
* The TAIL of the failed command's combined stdout+stderr (last
|
|
112
|
+
* {@link VERIFY_OUTPUT_TAIL_LINES} non-empty lines), so the surfaced
|
|
113
|
+
* needs-attention question carries the ACTUAL error text (e.g. "no changesets
|
|
114
|
+
* were found") rather than an opaque exit code. Bounded so a noisy gate cannot
|
|
115
|
+
* bloat the sidecar. Present ONLY on a failing result with a configured gate.
|
|
116
|
+
*/
|
|
117
|
+
outputTail?: string;
|
|
102
118
|
/**
|
|
103
119
|
* True iff the gate could not run because NO `verify` is declared (unset /
|
|
104
120
|
* empty / all-blank). A distinct, always-failing outcome (`passed: false`)
|
|
@@ -108,6 +124,15 @@ export interface RunVerifyResult {
|
|
|
108
124
|
notConfigured?: boolean;
|
|
109
125
|
}
|
|
110
126
|
|
|
127
|
+
/**
|
|
128
|
+
* How many trailing non-empty output lines of the FAILED gate command are kept
|
|
129
|
+
* in {@link RunVerifyResult.outputTail}. Small enough to keep the surfaced
|
|
130
|
+
* question readable, large enough to carry the actual error (most tool errors
|
|
131
|
+
* are 1–3 lines). The tail is captured per-command and reset on each command so
|
|
132
|
+
* only the failing command's output is retained.
|
|
133
|
+
*/
|
|
134
|
+
export const VERIFY_OUTPUT_TAIL_LINES = 20;
|
|
135
|
+
|
|
111
136
|
/**
|
|
112
137
|
* Run the resolved gate command(s) in `cwd`, streaming output, and resolve with
|
|
113
138
|
* the gate's status: exit 0 iff every command passed. Commands run in sequence;
|
|
@@ -142,17 +167,62 @@ export async function runVerify(
|
|
|
142
167
|
options.onStderr ?? ((chunk: string) => process.stderr.write(chunk));
|
|
143
168
|
|
|
144
169
|
for (const command of commands) {
|
|
170
|
+
// Capture a bounded ring of this command's combined output so a FAILURE can
|
|
171
|
+
// carry the actual error text (not just an exit code). Reset per command so
|
|
172
|
+
// only the failing command's tail is retained. The captured chunks still
|
|
173
|
+
// stream through the sinks unchanged (the console/log is unaffected).
|
|
174
|
+
const tail: string[] = [];
|
|
175
|
+
const capture = (chunk: string) => {
|
|
176
|
+
for (const line of chunk.split('\n')) {
|
|
177
|
+
tail.push(line);
|
|
178
|
+
}
|
|
179
|
+
// Keep a little slack over the reported budget; trimmed to the exact budget
|
|
180
|
+
// (non-empty lines only) when a failure surfaces.
|
|
181
|
+
const maxRing = VERIFY_OUTPUT_TAIL_LINES * 4;
|
|
182
|
+
if (tail.length > maxRing) {
|
|
183
|
+
tail.splice(0, tail.length - maxRing);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
145
186
|
const exitCode = await runOne(command, options.cwd, options.env, {
|
|
146
|
-
onStdout
|
|
147
|
-
|
|
187
|
+
onStdout: (chunk) => {
|
|
188
|
+
capture(chunk);
|
|
189
|
+
onStdout(chunk);
|
|
190
|
+
},
|
|
191
|
+
onStderr: (chunk) => {
|
|
192
|
+
capture(chunk);
|
|
193
|
+
onStderr(chunk);
|
|
194
|
+
},
|
|
148
195
|
});
|
|
149
196
|
if (exitCode !== 0) {
|
|
150
|
-
return {
|
|
197
|
+
return {
|
|
198
|
+
exitCode,
|
|
199
|
+
commands,
|
|
200
|
+
passed: false,
|
|
201
|
+
failedCommand: command,
|
|
202
|
+
outputTail: lastNonEmptyLines(tail, VERIFY_OUTPUT_TAIL_LINES),
|
|
203
|
+
};
|
|
151
204
|
}
|
|
152
205
|
}
|
|
153
206
|
return {exitCode: 0, commands, passed: true};
|
|
154
207
|
}
|
|
155
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Join the last `n` NON-EMPTY lines of a captured output ring into a single
|
|
211
|
+
* string (newline-separated), preserving order. Blank lines are dropped so the
|
|
212
|
+
* tail is dense signal (tool errors, not the trailing whitespace many gates
|
|
213
|
+
* emit). Returns `undefined` when nothing was captured, so callers can omit the
|
|
214
|
+
* context cleanly rather than surfacing an empty block.
|
|
215
|
+
*/
|
|
216
|
+
function lastNonEmptyLines(lines: string[], n: number): string | undefined {
|
|
217
|
+
const dense = lines
|
|
218
|
+
.map((line) => line.trimEnd())
|
|
219
|
+
.filter((line) => line !== '');
|
|
220
|
+
if (dense.length === 0) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return dense.slice(-n).join('\n');
|
|
224
|
+
}
|
|
225
|
+
|
|
156
226
|
/** Spawn one command via `bash -c`, streaming its output, resolving its code. */
|
|
157
227
|
function runOne(
|
|
158
228
|
command: string,
|