dorfl 0.5.0 → 0.6.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/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -5
- package/dist/cli.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 +10 -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/drive-tasks/SKILL.md +45 -2
- package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +10 -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/cli.ts +25 -5
- 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
|
@@ -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,
|