dorfl 0.11.3 → 0.13.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 (47) hide show
  1. package/dist/advance.d.ts +8 -1
  2. package/dist/advance.d.ts.map +1 -1
  3. package/dist/advance.js +39 -6
  4. package/dist/advance.js.map +1 -1
  5. package/dist/apply-decide.d.ts +7 -1
  6. package/dist/apply-decide.d.ts.map +1 -1
  7. package/dist/apply-decide.js +23 -10
  8. package/dist/apply-decide.js.map +1 -1
  9. package/dist/apply-persist.d.ts +52 -8
  10. package/dist/apply-persist.d.ts.map +1 -1
  11. package/dist/apply-persist.js +78 -9
  12. package/dist/apply-persist.js.map +1 -1
  13. package/dist/frontmatter.d.ts +17 -10
  14. package/dist/frontmatter.d.ts.map +1 -1
  15. package/dist/frontmatter.js +3 -3
  16. package/dist/frontmatter.js.map +1 -1
  17. package/dist/integration-core.d.ts.map +1 -1
  18. package/dist/integration-core.js +69 -0
  19. package/dist/integration-core.js.map +1 -1
  20. package/dist/protocol/CLAIM-PROTOCOL.md +22 -13
  21. package/dist/protocol/TASKING-PROTOCOL.md +1 -0
  22. package/dist/protocol/WORK-CONTRACT.md +8 -1
  23. package/dist/protocol/task-template.md +1 -1
  24. package/dist/review-gate.d.ts.map +1 -1
  25. package/dist/review-gate.js +9 -3
  26. package/dist/review-gate.js.map +1 -1
  27. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +22 -13
  28. package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +1 -0
  29. package/dist/skills/setup/protocol/WORK-CONTRACT.md +8 -1
  30. package/dist/skills/setup/protocol/task-template.md +1 -1
  31. package/dist/skills/triage-observations/SKILL.md +1 -1
  32. package/dist/triage-gate.d.ts +4 -2
  33. package/dist/triage-gate.d.ts.map +1 -1
  34. package/dist/triage-gate.js.map +1 -1
  35. package/dist/triage-persist.d.ts +55 -5
  36. package/dist/triage-persist.d.ts.map +1 -1
  37. package/dist/triage-persist.js +68 -7
  38. package/dist/triage-persist.js.map +1 -1
  39. package/package.json +1 -1
  40. package/src/advance.ts +52 -4
  41. package/src/apply-decide.ts +23 -10
  42. package/src/apply-persist.ts +86 -9
  43. package/src/frontmatter.ts +17 -10
  44. package/src/integration-core.ts +77 -0
  45. package/src/review-gate.ts +9 -3
  46. package/src/triage-gate.ts +4 -2
  47. package/src/triage-persist.ts +110 -9
package/src/advance.ts CHANGED
@@ -32,10 +32,13 @@ import type {ObservationTriage} from './config.js';
32
32
  import {
33
33
  autoDispositionObservation,
34
34
  promoteObservation,
35
+ stampTriagedMarker,
35
36
  type AutoDispositionOptions,
36
37
  type AutoDispositionResult,
37
38
  type PromoteObservationOptions,
38
39
  type PromoteObservationResult,
40
+ type StampTriagedMarkerOptions,
41
+ type StampTriagedMarkerResult,
39
42
  } from './triage-persist.js';
40
43
  import {mintAdr, type MintAdrOptions, type MintAdrResult} from './mint-adr.js';
41
44
  import {LIFECYCLE_CAS_CONTENTION} from './advancing-lock.js';
@@ -57,6 +60,8 @@ import {
57
60
  } from './surface-persist.js';
58
61
  import {
59
62
  applyAnsweredQuestions,
63
+ hasAppliedAnswersRecord,
64
+ TRIAGED_RESOLVE,
60
65
  type ApplyAnsweredQuestionsOptions,
61
66
  type ApplyAnsweredQuestionsResult,
62
67
  } from './apply-persist.js';
@@ -324,6 +329,15 @@ export interface AdvanceContext {
324
329
  * commit). Tests inject a spy; production uses {@link autoDispositionObservation}.
325
330
  */
326
331
  autoDisposition?: (options: AutoDispositionOptions) => AutoDispositionResult;
332
+ /**
333
+ * Back-stamp the `triaged:` settled marker on a note that was ALREADY
334
+ * resolved-and-kept before the apply rung learned to stamp it (the legacy arm of
335
+ * the triage rung's settled guard). Tests inject a spy; production uses
336
+ * {@link stampTriagedMarker}.
337
+ */
338
+ stampTriaged?: (
339
+ options: StampTriagedMarkerOptions,
340
+ ) => StampTriagedMarkerResult;
327
341
  /**
328
342
  * Promote an answered observation: CAS-create a new backlog stub keyed on the
329
343
  * NEW item's identity, then record + resolve the observation. Tests inject a
@@ -910,10 +924,20 @@ async function triageRung(input: RungExecInput): Promise<RungExecResult> {
910
924
  // note. (Replaces the old `detectObservationLimbo` special-case: there is no
911
925
  // limbo any more — an UNtriaged observation always surfaces the deterministic
912
926
  // question below; a SETTLED one is a calm no-op here.)
927
+ //
928
+ // SECOND ARM — the LEGACY back-fill (ADR
929
+ // `resolve-settles-the-question-loop-not-the-note`): a note the apply rung
930
+ // resolved-and-KEPT before it learned to stamp the marker carries the
931
+ // ENGINE-WRITTEN `## Applied answers` record but NO marker, which is the exact
932
+ // shape this rung would re-ask forever. That record is PROOF the engine already
933
+ // applied a human's answers to this note, so we stamp what the apply rung would
934
+ // have written and no-op. Self-healing and one-shot: the stamped note drops out
935
+ // of the pool, so this arm can never fire twice for the same note.
913
936
  {
914
937
  const itemRel = findItemPath(cwd, input.namespace, input.slug);
915
938
  if (itemRel !== undefined) {
916
- const fm = parseFrontmatter(readFileSync(join(cwd, itemRel), 'utf8'));
939
+ const body = readFileSync(join(cwd, itemRel), 'utf8');
940
+ const fm = parseFrontmatter(body);
917
941
  if (fm.triaged !== undefined && fm.triaged !== '') {
918
942
  return {
919
943
  exitCode: 0,
@@ -921,6 +945,21 @@ async function triageRung(input: RungExecInput): Promise<RungExecResult> {
921
945
  message: `triage ${item}: already triaged (triaged:${fm.triaged}) — nothing to do.`,
922
946
  };
923
947
  }
948
+ if (hasAppliedAnswersRecord(body)) {
949
+ const stamp = context.stampTriaged ?? stampTriagedMarker;
950
+ const result = stamp({
951
+ cwd,
952
+ item,
953
+ itemPath: itemRel,
954
+ value: TRIAGED_RESOLVE,
955
+ reason:
956
+ 'Back-filled: the note carries an engine-written applied-answers ' +
957
+ 'record, so its triage question-loop was already settled by a human ' +
958
+ 'answer before the apply rung stamped the marker.',
959
+ note,
960
+ });
961
+ return {exitCode: 0, outcome: 'no-op', message: result.message};
962
+ }
924
963
  }
925
964
  }
926
965
 
@@ -951,9 +990,10 @@ async function triageRung(input: RungExecInput): Promise<RungExecResult> {
951
990
  if (decision.auto === true) {
952
991
  // A no-question case (duplicate / map) — auto-disposition WITHOUT a
953
992
  // question. BOTH discharge the redundant note BY DELETION (a duplicate is a
954
- // redundant copy; a map is already covered by the item it maps onto). There
955
- // is no resting `triaged:keep` state any more. NEVER auto-deletes a
956
- // NON-redundant signal; NEVER auto-promotes.
993
+ // redundant copy; a map is already covered by the item it maps onto), so an
994
+ // AUTO disposition never RESTS a note — resting a KEPT note is the
995
+ // human-answered `resolve` verdict's job on the apply rung. NEVER auto-deletes
996
+ // a NON-redundant signal; NEVER auto-promotes.
957
997
  const dispose = context.autoDisposition ?? autoDispositionObservation;
958
998
  const result = dispose({
959
999
  cwd,
@@ -1654,6 +1694,14 @@ async function applyAgenticDecision(
1654
1694
  // the sibling of `dispose`, which git-rm's it or moves it to a terminal). `resolveReason` is advisory
1655
1695
  // context only; the durable disposition record is the harvested `## Applied
1656
1696
  // answers` block the resolve-fully path writes (NOT a separate convention).
1697
+ //
1698
+ // Because the note is KEPT, that same commit also stamps `triaged: resolve` on
1699
+ // it (ADR `resolve-settles-the-question-loop-not-the-note`): a kept note stays
1700
+ // in the inbox the triage rung scans, and without the marker its two
1701
+ // classifier signals (`needsAnswers:false`, no sidecar) are IDENTICAL to a note
1702
+ // that was never triaged — so the next tick re-surfaced the same engine-built
1703
+ // triage question forever. The stamp is done by the persist, not here, so no
1704
+ // caller of the resolve-fully path can forget it.
1657
1705
  const apply = context.applyPersist ?? applyAnsweredQuestions;
1658
1706
  try {
1659
1707
  const result = apply({cwd, item, itemPath, note});
@@ -40,7 +40,13 @@ import {
40
40
  * by the task `apply-decide-resolve-verdict-mint-nothing` so the decider can
41
41
  * honestly handle "the human answered, keep the note on record, mint nothing"
42
42
  * (previously it had no valid verdict for that case and looped on `ask`,
43
- * re-surfacing an already-answered question every tick). `adr` was DEFERRED at the
43
+ * re-surfacing an already-answered question every tick). Its MEANING was SHARPENED
44
+ * by ADR `resolve-settles-the-question-loop-not-the-note`: `resolve` settles the
45
+ * QUESTION-LOOP on a note that REMAINS a live signal (and the persist stamps
46
+ * `triaged:` so the triage rung stops re-asking it, closing a second re-ask loop
47
+ * the first fix left open); a note whose SIGNAL is finished is `dispose`, not
48
+ * `resolve` — a dead note kept and stamped "resolved" is exactly the
49
+ * backward-artifact-in-a-forward-bucket the work contract forbids. `adr` was DEFERRED at the
44
50
  * keystone launch (no
45
51
  * ADR-mint path existed yet) and is now WIRED by the follow-on task
46
52
  * `agentic-apply-mint-adr-route`, which added the {@link
@@ -201,15 +207,22 @@ export function buildApplyDecisionPrompt(input: ApplyDecisionInput): string {
201
207
  ` \`reason:\` frontmatter); a SPEC is git-mv-ed to \`specs/dropped/\` (RETAINED).`,
202
208
  ` A task can NEVER be hard-deleted by the apply rung \u2014 dispose is the`,
203
209
  ` only path off the board.`,
204
- ` - "resolve": the answer SETTLES this item and there is NOTHING to mint (no`,
205
- ` task/spec/adr) \u2014 the correct move is to CLOSE the question-loop while`,
206
- ` KEEPING the note on record (e.g. an evidence/watch-item observation whose`,
207
- ` answer is "acknowledged, keep this on record, no artifact"). The answers are`,
208
- ` harvested into the item body and the loop is cleared; the note is RETAINED`,
209
- ` (this is the sibling of "dispose", which DROPS the observation-note or`,
210
- ` moves a task/spec to its terminal \u2014 pick "resolve" when the observation`,
211
- ` should SURVIVE in place, "dispose" when it should not). Emit`,
212
- ` {"outcome":"resolve","resolveReason":"\u2026"}.`,
210
+ ` - "resolve": the answer SETTLES the QUESTION-LOOP and there is NOTHING to`,
211
+ ` mint (no task/spec/adr), AND the note is STILL A LIVE SIGNAL worth keeping`,
212
+ ` in the inbox \u2014 e.g. a standing map of a known gap, or accepted residue the`,
213
+ ` answer explicitly says to keep on record until it is acted on. The answers`,
214
+ ` are harvested into the item body, the loop is cleared, and the note is`,
215
+ ` RETAINED and STAMPED as triaged, so it is never re-asked this question.`,
216
+ ` Emit {"outcome":"resolve","resolveReason":"\u2026"}.`,
217
+ ` PICK BETWEEN "resolve" AND "dispose" ON LIVENESS, NOT ON POLITENESS: both`,
218
+ ` close the loop and mint nothing, and the ONLY question is whether the note`,
219
+ ` is still a live signal AFTER this answer. If the answer means the signal is`,
220
+ ` FINISHED (the thing was fixed, it was already covered elsewhere, it is`,
221
+ ` obsolete, or its whole content is now carried by a task/spec/ADR/commit),`,
222
+ ` that note stops being live \u2014 emit "dispose", NOT "resolve". "resolve" is`,
223
+ ` only correct when the note still carries a signal nothing else records.`,
224
+ ` Do NOT use "resolve" as a soft "dispose": a note kept only to show it was`,
225
+ ` handled is a backward artifact in a forward bucket.`,
213
226
  ` - "ask": you need more from the human before acting. Emit`,
214
227
  ` {"outcome":"ask","question":"\u2026"} \u2014 ask everything you still need as ONE`,
215
228
  ` batch (never a drip); the engine appends it and re-pauses.`,
@@ -42,17 +42,26 @@ import {workItemRel} from './work-layout.js';
42
42
  * hard-deleted from here — dispose is the only path off the board; OR
43
43
  * - **resolve fully** (the default) — clear `needsAnswers` + DELETE the sidecar
44
44
  * in the SAME atomic commit (the invariant `needsAnswers:false ⟺ no active
45
- * sidecar`); the item advances toward build by its normal lifecycle.
45
+ * sidecar`); the item advances toward build by its normal lifecycle. For an
46
+ * OBSERVATION this ALSO stamps the `triaged:` settled marker (see
47
+ * {@link TRIAGED_RESOLVE}) in that same commit — the note is KEPT, so it needs
48
+ * a durable record that its triage question-loop is CLOSED or the triage rung
49
+ * re-asks it forever (ADR `resolve-settles-the-question-loop-not-the-note`).
46
50
  *
47
51
  * **The disposition vocabulary is GONE** (task
48
52
  * `agentic-apply-retire-disposition-vocabulary`): the apply rung no longer reads a
49
- * per-entry `disposition=` token, no longer runs a most-decisive picker, and has
50
- * no `keep`/`triaged:keep` resting state. A sidecar entry is BINARY (no-answer |
51
- * answered); what to DO with a fully-answered OBSERVATION is decided by the
52
- * AGENTIC apply decision in the advance tick (`advance.ts` `applyRung` over the
53
- * shared `decide` engine), which then routes here (re-pause / dispose / via
54
- * `promoteObservation` for a mint). A signal is still-open, acted-on, or deleted
55
- * — there is no \"retain as resolved\" state.
53
+ * per-entry `disposition=` token and no longer runs a most-decisive picker. A
54
+ * sidecar entry is BINARY (no-answer | answered); what to DO with a fully-answered
55
+ * OBSERVATION is decided by the AGENTIC apply decision in the advance tick
56
+ * (`advance.ts` `applyRung` over the shared `decide` engine), which then routes
57
+ * here (re-pause / dispose / resolve / via `promoteObservation` for a mint).
58
+ *
59
+ * What the retirement removed was the human-stamped `disposition=`/`promote-*`
60
+ * TOKEN vocabulary, NOT the settled AXIS: a note that the `resolve` verdict KEEPS
61
+ * still rests as `triaged: resolve` + `needsAnswers:false` + no sidecar. That is
62
+ * the one resting state a note has, and it says "the question-loop is settled",
63
+ * NOT "the signal is finished" (a finished signal is `dispose`d — it leaves the
64
+ * inbox by deletion).
56
65
  *
57
66
  * The work-item (task/spec) terminal MOVES (`tasks/cancelled`, `specs/dropped`) and
58
67
  * the stuck (lock `state: stuck`) LIFECYCLE state are a SEPARATE lifecycle concern (a
@@ -87,6 +96,32 @@ export {APPLY_LIFECYCLE_FOLDERS, resolveItemPathByIdentity};
87
96
  /** Marker that opens the applied-answers record in an item body. */
88
97
  const APPLIED_HEADING = '## Applied answers';
89
98
 
99
+ /**
100
+ * The `triaged:` settled-marker VALUE the resolve-fully path stamps on a KEPT
101
+ * OBSERVATION (ADR `resolve-settles-the-question-loop-not-the-note`).
102
+ *
103
+ * The `resolve` verdict is the ONE apply terminal that RETAINS an observation in
104
+ * the inbox. Every other terminal removes it from the triage pool by MOVING or
105
+ * DELETING the file (mint → `git rm` + the new artifact; dispose → `git rm` /
106
+ * terminal folder), so only this one needs an in-file record that the note has
107
+ * been through triage. Without it the resolved note is byte-indistinguishable, to
108
+ * the classifier's two signals (`needsAnswers` + sidecar), from a note that was
109
+ * NEVER triaged: `needsAnswers:false` + no sidecar ⇒ ANALYSE ⇒ `triage-observation`
110
+ * ⇒ the engine-built triage question is surfaced AGAIN, and the human is asked
111
+ * something they already answered (observed live in the `rocketh` consumer repo:
112
+ * surface → answer → resolve → the byte-identical question re-surfaced).
113
+ *
114
+ * The marker is the axis the READ side already models end-to-end — `frontmatter.ts`
115
+ * parses it, `ledger-read.ts` carries it, `lifecycle-pools.ts` drops a marked
116
+ * observation out of the CREATE-side triage pool, and `advance.ts`'s triage rung
117
+ * no-ops on it for an explicit `obs:<slug>`. Only the WRITER was missing.
118
+ *
119
+ * It does NOT strand a later answer: an ANSWERED sidecar dominates the marker (ADR
120
+ * `answered-observation-sidecar-dominates-triaged-marker`), so a genuinely NEW
121
+ * question about a settled note still routes to apply and is still answerable.
122
+ */
123
+ export const TRIAGED_RESOLVE = 'resolve';
124
+
90
125
  /**
91
126
  * The STRUCTURAL fence the templates wrap the transient open-questions block in
92
127
  * — an HTML-comment marker pair, mirroring the existing `<!-- dorfl-…
@@ -108,6 +143,21 @@ const APPLIED_HEADING = '## Applied answers';
108
143
  export const OPEN_QUESTIONS_MARKER_OPEN = '<!-- open-questions -->';
109
144
  export const OPEN_QUESTIONS_MARKER_CLOSE = '<!-- /open-questions -->';
110
145
 
146
+ /**
147
+ * Does this item body carry the ENGINE-WRITTEN applied-answers record?
148
+ *
149
+ * Only {@link applyAnsweredQuestions} writes this heading, and only after a human
150
+ * answered EVERY open question on the item, so its presence is PROOF the engine
151
+ * has applied a human's answers here — not a prose heuristic. The triage rung uses
152
+ * it as the legacy back-fill trigger for notes resolved-and-kept before the
153
+ * `triaged:` stamp existed (ADR `resolve-settles-the-question-loop-not-the-note`).
154
+ */
155
+ export function hasAppliedAnswersRecord(body: string): boolean {
156
+ return new RegExp(`^${APPLIED_HEADING}\\b`, 'm').test(
157
+ body.replace(/\r\n/g, '\n'),
158
+ );
159
+ }
160
+
111
161
  export interface ApplyAnsweredQuestionsOptions {
112
162
  /** Working clone/worktree the apply commits in. */
113
163
  cwd: string;
@@ -490,7 +540,10 @@ export function applyAnsweredQuestions(
490
540
  // compatible — no marker ⇒ identical bytes. The re-pause path above is
491
541
  // deliberately untouched (D3): its open-questions block is still open.
492
542
  const reconciledBody = stripOpenQuestionsBlocks(baseBody);
493
- const resolvedBody = withAppliedAnswers(reconciledBody, model.entries);
543
+ const resolvedBody = withTriagedMarkerIfKeptNote(
544
+ withAppliedAnswers(reconciledBody, model.entries),
545
+ item,
546
+ );
494
547
  const result = applyAtomic({
495
548
  cwd,
496
549
  itemPath,
@@ -512,6 +565,30 @@ export function applyAnsweredQuestions(
512
565
  };
513
566
  }
514
567
 
568
+ /**
569
+ * Stamp the `triaged:` settled marker on a resolve-fully body — but ONLY for an
570
+ * OBSERVATION (the one item type this path KEEPS in a re-scanned inbox).
571
+ *
572
+ * Structural, not caller-supplied, so it cannot be forgotten by a future caller:
573
+ * every route that reaches the resolve-fully branch with an observation identity
574
+ * has, by definition, just closed that note's question-loop while retaining the
575
+ * file. A TASK/SPEC is deliberately EXCLUDED — it rests in a lifecycle FOLDER (its
576
+ * status), is not enumerated from the observation inbox, and has no triage rung to
577
+ * re-ask it; stamping a triage marker on one would invent a second status axis for
578
+ * an item whose status is already the folder.
579
+ *
580
+ * Idempotent ({@link setFrontmatterMarker} replaces an existing key) and
581
+ * fence-creating (observations are routinely born fence-less), so re-resolving a
582
+ * note after a genuinely new question re-stamps rather than duplicating.
583
+ */
584
+ function withTriagedMarkerIfKeptNote(body: string, item: string): string {
585
+ const {type} = resolveSidecarIdentity(item);
586
+ if (type !== 'observation') {
587
+ return body;
588
+ }
589
+ return setFrontmatterMarker(body, 'triaged', TRIAGED_RESOLVE);
590
+ }
591
+
515
592
  interface DisposeInput {
516
593
  cwd: string;
517
594
  item: string;
@@ -94,13 +94,20 @@ export interface Frontmatter {
94
94
  */
95
95
  needsAnswers: boolean | undefined;
96
96
  /**
97
- * The triage SETTLED marker (US #30). A non-empty `triaged:` value (e.g.
98
- * `keep` / `duplicate`) means a human (or the conservative auto-disposition)
99
- * has SETTLED this observation, so it DROPS OUT of the triage candidate pool
100
- * and is never re-asked. `undefined` when omitted (an UNTRIAGED observation,
101
- * still in the pool). Carried so the lifecycle-pool enumeration
102
- * (`advance-autopick-lifecycle-pools`) can exclude settled observations from
103
- * the triage selection.
97
+ * The triage SETTLED marker (US #30). A non-empty `triaged:` value means this
98
+ * observation's triage QUESTION-LOOP has been settled by a human's answer, so it
99
+ * DROPS OUT of the triage candidate pool and is never re-asked. `undefined` when
100
+ * omitted (an UNTRIAGED observation, still in the pool). Carried so the
101
+ * lifecycle-pool enumeration (`advance-autopick-lifecycle-pools`) can exclude
102
+ * settled observations from the triage selection.
103
+ *
104
+ * The one WRITER today is the apply rung's `resolve` verdict, which stamps
105
+ * `triaged: resolve` on a note it settles and KEEPS (ADR
106
+ * `resolve-settles-the-question-loop-not-the-note`). It says the question-loop is
107
+ * settled, NOT that the signal is finished — a finished signal has no resting
108
+ * state at all (it is deleted), and an ANSWERED sidecar still dominates this
109
+ * marker (ADR `answered-observation-sidecar-dominates-triaged-marker`), so a
110
+ * genuinely new question about a settled note is still asked and answered.
104
111
  */
105
112
  triaged: string | undefined;
106
113
  /** Slugs this item is blocked by; `[]` when omitted or empty. */
@@ -241,9 +248,9 @@ export function setNeedsAnswersMarker(content: string, value: boolean): string {
241
248
  /**
242
249
  * Set (or replace) a top-level scalar `<key>: <value>` frontmatter marker on a
243
250
  * `work/` markdown document, returning the new content. The generalised form of
244
- * {@link setNeedsAnswersMarker} the advance APPLY rung uses to stamp a
245
- * `triaged: keep` marker (US #30) on an item a human answered "keep" — so a
246
- * settled observation drops out of the candidate pool and is never re-asked. If a
251
+ * {@link setNeedsAnswersMarker} the advance APPLY rung uses to stamp the
252
+ * `triaged: resolve` marker (US #30) on a note a human's answer settles but KEEPS
253
+ * — so a settled observation drops out of the candidate pool and is never re-asked. If a
247
254
  * `<key>:` line already exists it is REPLACED (idempotent); otherwise it is
248
255
  * appended as the last line inside the frontmatter fence.
249
256
  *
@@ -28,6 +28,7 @@ import {
28
28
  reviewRoundsExhaustedReason,
29
29
  } from './review-gate.js';
30
30
  import {type IntegrateResult, type ReviewProvider} from './integrator.js';
31
+ import {extractDecisionsBlock} from './agent-stop.js';
31
32
  import {ledgerWrite} from './ledger-write.js';
32
33
  import {selectProvider} from './github.js';
33
34
  import type {IntegrationMode} from './config.js';
@@ -1104,6 +1105,20 @@ export async function performIntegration(
1104
1105
  );
1105
1106
  }
1106
1107
 
1108
+ // 2b. TRANSCRIBE the agent's `## Decisions` block into the DONE RECORD (the
1109
+ // builder's rationale channel, WORK-CONTRACT "Where a BUILDER's RATIONALE
1110
+ // lives"). The builder does NO git and MUST NOT edit the task body, and the
1111
+ // done-move is ours, so "record your rationale in the done record" is an
1112
+ // instruction it CANNOT obey directly. It emits a `## Decisions` block on the
1113
+ // one surface it owns (its final report, reaching us as `input.body`), and we
1114
+ // append it here: AFTER the move (so we write the record at its DONE path) and
1115
+ // BEFORE the `git add -A` below (so it rides the SAME atomic completion commit,
1116
+ // not a second one). Skipped for a TASKING transition (`lifecycle`), which
1117
+ // lands a spec: no done record, and no building agent.
1118
+ if (!lifecycle) {
1119
+ transcribeDecisionsIntoDoneRecord({cwd, slug, output: input.body, note});
1120
+ }
1121
+
1107
1122
  // 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
1108
1123
  // atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
1109
1124
  await gitHard(['add', '-A'], cwd, env);
@@ -2530,6 +2545,68 @@ const CAPTURE_NOTE_DIRS = [
2530
2545
  workFolderPrefix('findings'),
2531
2546
  ] as const;
2532
2547
 
2548
+ /**
2549
+ * TRANSCRIBE the build agent's `## Decisions` block into the DONE RECORD: the
2550
+ * runner half of the builder's rationale channel (WORK-CONTRACT.md, "Where a
2551
+ * BUILDER's RATIONALE lives").
2552
+ *
2553
+ * The builder MUST surface a non-obvious in-scope decision for ratification, but
2554
+ * every home the contract used to offer it was UNREACHABLE: it does no git, it must
2555
+ * not edit the task body (`CLAIM-PROTOCOL.md`), and the done-move, the completion
2556
+ * commit and the PR body are all the RUNNER's. So an acceptance criterion like
2557
+ * "rationale recorded in the done record" was structurally unsatisfiable, and the
2558
+ * rationale scattered into a JSDoc, a changeset, or an invented
2559
+ * `work/notes/observations/decisions-<slug>.md` note (a BACKWARD artifact in a LIVE,
2560
+ * FORWARD bucket, so it can never be discharged). Reviewers then re-raised the
2561
+ * identical "no Decisions block in the done record" finding on task after task.
2562
+ *
2563
+ * The fix is structural, not exhortative: the builder emits the block on the ONE
2564
+ * surface it owns (its final report, which reaches us as `input.body`), and the
2565
+ * runner, already the owner of the done-move, appends it here. Properties that
2566
+ * matter:
2567
+ *
2568
+ * - **Same commit.** The caller invokes this AFTER the `git mv` (so the record is
2569
+ * written at its `tasks/done/` path) and BEFORE `git add -A` (so it rides the
2570
+ * ONE atomic completion commit, never a second, dangling one).
2571
+ * - **Verbatim.** The agent's prose is appended unaltered under a `## Decisions`
2572
+ * heading; we add structure, never interpretation.
2573
+ * - **Idempotent.** A record that ALREADY carries a `## Decisions` section is left
2574
+ * untouched, so a `requeue` continue, or a re-run over an already-done-moved
2575
+ * branch (`source: 'done'`), cannot append the same block twice.
2576
+ * - **Best-effort.** No block, no record on disk, or an unreadable/unwritable file
2577
+ * is a silent no-op: a rationale note must NEVER fail an otherwise-green
2578
+ * completion (the same stance as {@link reportScoopedNotes}).
2579
+ */
2580
+ function transcribeDecisionsIntoDoneRecord(params: {
2581
+ cwd: string;
2582
+ slug: string;
2583
+ output: string | undefined;
2584
+ note: (message: string) => void;
2585
+ }): void {
2586
+ const {cwd, slug, output, note} = params;
2587
+ const decisions = extractDecisionsBlock(output);
2588
+ if (decisions === undefined) {
2589
+ return; // the common case: the agent recorded no in-scope decision.
2590
+ }
2591
+ const path = workItemPath(cwd, 'done', slug);
2592
+ if (!existsSync(path)) {
2593
+ return; // no done record to annotate (a lifecycle/degenerate shape).
2594
+ }
2595
+ try {
2596
+ const body = readFileSync(path, 'utf8');
2597
+ if (/^##\s+Decisions\s*$/m.test(body)) {
2598
+ return; // already transcribed (a continue/re-run): never duplicate.
2599
+ }
2600
+ const separator = body.endsWith('\n') ? '\n' : '\n\n';
2601
+ writeFileSync(path, `${body}${separator}## Decisions\n\n${decisions}\n`);
2602
+ note(
2603
+ `Recorded the agent's "## Decisions" block in ${workItemRel('done', `${slug}.md`)}.`,
2604
+ );
2605
+ } catch {
2606
+ // Best-effort: never fail a green completion over a rationale note.
2607
+ }
2608
+ }
2609
+
2533
2610
  /**
2534
2611
  * SCOOP + REPORT the agent-authored CAPTURED NOTES this run's atomic commit is
2535
2612
  * landing (task `runner-scoops-captured-notes`). A rung's agent writes
@@ -126,12 +126,18 @@ export function buildReviewPrompt(slug: string): string {
126
126
  `design choice the agent made on its own while building: a CROSS-TASK`,
127
127
  `INTERACTION (a choice affecting another command/flag/task's behaviour), a new`,
128
128
  `ERROR/REFUSAL, or a user-visible DEFAULT. The agent SHOULD have recorded these`,
129
- `in a "## Decisions" block in its PR description — START from that block (ratify`,
129
+ `in a "## Decisions" block in its report, which the runner transcribes into the`,
130
+ `done record (work/tasks/done/<slug>.md) and which also rides the PR description.`,
131
+ `START from that block (ratify`,
130
132
  `each entry) AND hunt for any it MISSED. Flag EACH such decision as a finding`,
131
- `for the human to RATIFY — "non-blocking" by DEFAULT (the build proceeds; the`,
133
+ `for the human to RATIFY: "non-blocking" by DEFAULT (the build proceeds; the`,
132
134
  `human ratifies or reverses), escalating to "blocking" ONLY if the decision looks`,
133
135
  `WRONG or is genuinely load-bearing-and-hard-to-reverse. An un-recorded in-scope`,
134
- `decision is NOT itself a block — it is a ratification finding.`,
136
+ `decision is NOT itself a block, it is a ratification finding. Do NOT raise a`,
137
+ `finding about WHERE the rationale was written (the done record, the commit body`,
138
+ `and the PR body are the RUNNER's to author, never the builder's): review the`,
139
+ `DECISION, not the filing. If rationale is missing entirely, that is the`,
140
+ `ratification finding above.`,
135
141
  ``,
136
142
  `ALSO CHECK CONCEPTUAL COHERENCE — does this diff fit the system's existing`,
137
143
  `LANGUAGE? For each concept / flag / config key / status / verb it introduces or`,
@@ -26,8 +26,10 @@ import {extractJsonObjectSpan} from './verdict-json.js';
26
26
  * - `map` — an UNAMBIGUOUS map onto an existing item → DISCHARGE the redundant
27
27
  * note BY DELETION too (it is already covered by the item it maps onto, so it
28
28
  * carries no unique signal; the mapping is recorded in the commit message).
29
- * There is no resting `triaged:keep` state any more (task
30
- * `agentic-apply-retire-disposition-vocabulary`).
29
+ * An AUTO disposition never RESTS a note (task
30
+ * `agentic-apply-retire-disposition-vocabulary`); the one path that KEEPS a
31
+ * note is the human-answered `resolve` verdict on the apply rung (ADR
32
+ * `resolve-settles-the-question-loop-not-the-note`).
31
33
  *
32
34
  * It is the DIRECT mirror of `surface-gate.ts`'s spawn→emit→parse seam, with a
33
35
  * narrower emitted payload (`{auto, …}` rather than `{questions}`). Production
@@ -1,4 +1,4 @@
1
- import {readFileSync} from 'node:fs';
1
+ import {readFileSync, writeFileSync} from 'node:fs';
2
2
  import {join} from 'node:path';
3
3
  import {run, type RunResult} from './git.js';
4
4
  import {workItemRel} from './work-layout.js';
@@ -25,8 +25,12 @@ import {setFrontmatterMarker} from './frontmatter.js';
25
25
  * 1. **the conservative auto-disposition** ({@link autoDispositionObservation},
26
26
  * US #17, `observationTriage: 'auto'`-gated): act on the no-question case on
27
27
  * the UNTRIAGED observation in ONE local commit. BOTH no-question cases now
28
- * DISCHARGE the redundant note BY DELETION (there is no resting `triaged:keep`
29
- * state any more — task `agentic-apply-retire-disposition-vocabulary`):
28
+ * DISCHARGE the redundant note BY DELETION — an AUTO disposition never RESTS a
29
+ * note, because both of its cases (duplicate / map) are by definition notes
30
+ * that carry no unique signal (task `agentic-apply-retire-disposition-
31
+ * vocabulary`; the ONE case that does rest a note is the human-answered
32
+ * `resolve` verdict on the APPLY rung, see ADR `resolve-settles-the-question-
33
+ * loop-not-the-note`):
30
34
  * - `duplicate` → `git rm` the duplicate in a standalone commit, the
31
35
  * duplicated-of identity + reason in the commit message (git history =
32
36
  * archive). A duplicate is a redundant copy of an already-captured signal,
@@ -34,7 +38,6 @@ import {setFrontmatterMarker} from './frontmatter.js';
34
38
  * - `map` → the note is already covered by the existing item it maps onto, so
35
39
  * it is settled — `git rm` it in a standalone commit, the mapped-onto
36
40
  * identity + reason recorded in the commit message (mirroring `duplicate`).
37
- * There is no resting `triaged:keep` note any more.
38
41
  * 2. **promote → SELF-CONTAINED new-item creation + DELETE through the CAS**
39
42
  * ({@link promoteObservation}, US #1/#3/#8): an ANSWERED "promote" drafts a
40
43
  * new `work/tasks/ready/<new-slug>.md` whose body is built FROM the
@@ -120,6 +123,102 @@ export class TriagePersistError extends Error {
120
123
  }
121
124
  }
122
125
 
126
+ // --- Back-stamping a KEPT note that was resolved before the marker existed ---
127
+
128
+ export interface StampTriagedMarkerOptions {
129
+ /** Working clone/worktree the stamp commits in. */
130
+ cwd: string;
131
+ /** The namespaced observation identity (`observation:<slug>`). */
132
+ item: string;
133
+ /** The observation file path RELATIVE to `cwd`. */
134
+ itemPath: string;
135
+ /** The `triaged:` value to stamp (the apply rung's settled disposition). */
136
+ value: string;
137
+ /** Why the stamp is being back-filled (rides the commit message). */
138
+ reason: string;
139
+ /** Advisory committer id for the commit subject. Defaults to git user.name. */
140
+ by?: string;
141
+ /** Environment for child git processes. */
142
+ env?: NodeJS.ProcessEnv;
143
+ /** Sink for human-readable progress notes. */
144
+ note?: (message: string) => void;
145
+ }
146
+
147
+ export interface StampTriagedMarkerResult {
148
+ /** The commit sha the stamp produced, or `undefined` when the body was already marked. */
149
+ commit?: string;
150
+ /** A human-readable summary. */
151
+ message: string;
152
+ }
153
+
154
+ /**
155
+ * Stamp the `triaged:` settled marker onto an observation that was ALREADY
156
+ * resolved-and-kept, in ONE local commit — the SELF-HEALING back-fill for notes
157
+ * resolved BEFORE the apply rung learned to stamp it (ADR
158
+ * `resolve-settles-the-question-loop-not-the-note`).
159
+ *
160
+ * Why a back-fill exists at all: the fix that makes `resolve` stamp the marker is
161
+ * forward-only, so every note a repo resolved under the old code still rests
162
+ * `needsAnswers:false` + no sidecar + NO marker — the exact shape the triage rung
163
+ * re-asks. Those notes are not hypothetical (a single `rocketh` cycle re-asked
164
+ * four, with eleven more queued behind them), and they are un-fixable from the
165
+ * human's side: answering again just resolves again into the same shape.
166
+ *
167
+ * The trigger is PROOF, not a heuristic: the caller fires this only when the body
168
+ * carries the ENGINE-WRITTEN `## Applied answers` record, which only
169
+ * `apply-persist.ts` writes and only after a human answered every open question.
170
+ * A hand-written note cannot accidentally acquire it, and the stamp is exactly
171
+ * what the apply rung WOULD have written had it known how.
172
+ *
173
+ * ONE commit touching ONE file, so the tree-less publish (`pushTreelessResult`)
174
+ * carries it to the arbiter like any other triage-rung marker write; once landed
175
+ * the note drops out of the triage pool and this path is never taken again.
176
+ */
177
+ export function stampTriagedMarker(
178
+ options: StampTriagedMarkerOptions,
179
+ ): StampTriagedMarkerResult {
180
+ const {cwd, item, itemPath, value, reason, env} = options;
181
+ const note = options.note ?? (() => {});
182
+
183
+ if (gitSoft(['rev-parse', '--git-dir'], cwd, env).status !== 0) {
184
+ throw new TriagePersistError('not inside a git repository');
185
+ }
186
+
187
+ const abs = join(cwd, itemPath);
188
+ const before = readFileSync(abs, 'utf8');
189
+ const after = setFrontmatterMarker(before, 'triaged', value);
190
+ if (after === before) {
191
+ // Already marked (or a malformed fence `setFrontmatterMarker` refuses to
192
+ // rewrite) — nothing to commit; never produce an empty commit.
193
+ const message = `triage ${item}: already carries triaged:${value} — no stamp needed.`;
194
+ note(message);
195
+ return {message};
196
+ }
197
+ writeFileSync(abs, after);
198
+ const by = options.by || resolveBy(cwd, env);
199
+ gitHard(['add', '--', itemPath], cwd, env);
200
+ const subject = `advance: triage ${item} → settled (by ${by})`;
201
+ gitHard(
202
+ [
203
+ 'commit',
204
+ '--quiet',
205
+ '-m',
206
+ subject,
207
+ '-m',
208
+ `triaged: ${value}\n\n${reason}`,
209
+ ],
210
+ cwd,
211
+ env,
212
+ );
213
+ const commit = gitHard(['rev-parse', 'HEAD'], cwd, env).stdout.trim();
214
+ const message =
215
+ `triage ${item}: back-stamped triaged:${value} (the note was already ` +
216
+ 'resolved-and-kept — it carries an engine-written applied-answers record) ' +
217
+ 'so the triage rung stops re-asking its settled question.';
218
+ note(message);
219
+ return {commit, message};
220
+ }
221
+
123
222
  // --- The conservative auto-disposition (US #17) ---------------------------
124
223
 
125
224
  export interface AutoDispositionOptions {
@@ -149,8 +248,10 @@ export interface AutoDispositionResult {
149
248
  * DELETION (`git rm` in a standalone commit, the reason in the message). A
150
249
  * `duplicate` is a redundant copy of `existing`; a `map` is already covered by
151
250
  * `existing` — either way the note carries no unique signal, so it leaves the
152
- * inbox by being gone. There is no resting `triaged:keep` state any more (task
153
- * `agentic-apply-retire-disposition-vocabulary`). NEVER an auto-delete of a
251
+ * inbox by being gone. An AUTO disposition never RESTS a note (task
252
+ * `agentic-apply-retire-disposition-vocabulary`); resting a KEPT note is the
253
+ * human-answered `resolve` verdict's job on the apply rung (ADR
254
+ * `resolve-settles-the-question-loop-not-the-note`). NEVER an auto-delete of a
154
255
  * NON-redundant signal.
155
256
  */
156
257
  outcome: 'deleted';
@@ -167,9 +268,9 @@ export interface AutoDispositionResult {
167
268
  * ONE local commit, no question surfaced. BOTH no-question cases DISCHARGE the
168
269
  * redundant note BY DELETION (`git rm` in a STANDALONE commit, the mapped/
169
270
  * duplicated-of identity + reason in the commit message; git history = archive).
170
- * There is no resting `triaged:keep` state any more (task
171
- * `agentic-apply-retire-disposition-vocabulary` — a signal is still-open,
172
- * acted-on, or deleted):
271
+ * An AUTO disposition never RESTS a note (task
272
+ * `agentic-apply-retire-disposition-vocabulary`): its two cases carry no unique
273
+ * signal, so they are still-open, acted-on, or deleted — never kept:
173
274
  *
174
275
  * - `duplicate` → the note is an EXACT duplicate of `existing` (already
175
276
  * captured); the original carries the signal, so the copy is deleted; or