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.
Files changed (41) hide show
  1. package/dist/apply-merge-action.js +1 -1
  2. package/dist/apply-merge-action.js.map +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +25 -5
  5. package/dist/cli.js.map +1 -1
  6. package/dist/complete.d.ts +1 -1
  7. package/dist/complete.d.ts.map +1 -1
  8. package/dist/complete.js +14 -0
  9. package/dist/complete.js.map +1 -1
  10. package/dist/integration-core.d.ts +24 -1
  11. package/dist/integration-core.d.ts.map +1 -1
  12. package/dist/integration-core.js +120 -4
  13. package/dist/integration-core.js.map +1 -1
  14. package/dist/protocol/CLAIM-PROTOCOL.md +10 -0
  15. package/dist/protocol/WORK-CONTRACT.md +5 -1
  16. package/dist/run.d.ts.map +1 -1
  17. package/dist/run.js +6 -0
  18. package/dist/run.js.map +1 -1
  19. package/dist/sidecar-guard.d.ts +72 -0
  20. package/dist/sidecar-guard.d.ts.map +1 -0
  21. package/dist/sidecar-guard.js +118 -0
  22. package/dist/sidecar-guard.js.map +1 -0
  23. package/dist/skills/drive-tasks/SKILL.md +45 -2
  24. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +10 -0
  25. package/dist/skills/setup/protocol/WORK-CONTRACT.md +5 -1
  26. package/dist/tasking.d.ts.map +1 -1
  27. package/dist/tasking.js +31 -0
  28. package/dist/tasking.js.map +1 -1
  29. package/dist/verify.d.ts +24 -0
  30. package/dist/verify.d.ts.map +1 -1
  31. package/dist/verify.js +55 -3
  32. package/dist/verify.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/apply-merge-action.ts +1 -1
  35. package/src/cli.ts +25 -5
  36. package/src/complete.ts +15 -0
  37. package/src/integration-core.ts +146 -4
  38. package/src/run.ts +6 -0
  39. package/src/sidecar-guard.ts +142 -0
  40. package/src/tasking.ts +33 -0
  41. 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
- onStderr,
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 {exitCode, commands, passed: false};
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,