memhtml 0.3.0 → 0.4.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.
@@ -1,4 +1,4 @@
1
- import { $ as INBOX_DIR, B as ModelUnavailable, Ct as filenameFor, H as StorageFailure, J as relClassFor, L as DirtyTree, Q as ARCS_DIR, R as InvalidMemory, St as SLUG_FALLBACK, Tt as withCollisionOrdinal, U as WriteConflict, V as PathNotFound, W as EdgeRel, X as relTokenFor, Y as relForToken, Z as ARCHIVE_BUCKET, _t as TASK_STATUSES, at as isValidMemoryPath, bt as isTaskStatus, ct as paraBucketOf, dt as Importance, et as MEMORY_EXTENSION, ft as MEMORY_TYPES, gt as PERSON_ENTITY_PREFIX, ht as PARA_BUCKETS, it as isArchivePath, lt as placementFor, mt as MemoryType, ot as memoryPathFor, pt as MemoryStatus, q as isEdgeRel, rt as archivePathFor, st as normalizePath, tt as PEOPLE_DIR, ut as Confidence, vt as TaskStatus, wt as slugify, xt as parseEntity, yt as WRITABLE_MEMORY_TYPES, z as LlmContractViolation } from "./dist-CSo_XRfz.mjs";
1
+ import { At as filenameFor, Ct as PERSON_ENTITY_PREFIX, Dt as isTaskStatus, Et as WRITABLE_MEMORY_TYPES, G as InvalidMemory, J as PathNotFound, K as LlmContractViolation, Mt as withCollisionOrdinal, Ot as parseEntity, St as PARA_BUCKETS, Tt as TaskStatus, W as DirtyTree, X as WriteConflict, Y as StorageFailure, Z as EdgeRel, _t as Confidence, at as ARCS_DIR, bt as MemoryStatus, ct as PEOPLE_DIR, dt as isArchivePath, et as isEdgeRel, ft as isValidMemoryPath, gt as placementFor, ht as paraBucketOf, it as ARCHIVE_BUCKET, jt as slugify, kt as SLUG_FALLBACK, mt as normalizePath, nt as relForToken, ot as INBOX_DIR, pt as memoryPathFor, q as ModelUnavailable, rt as relTokenFor, st as MEMORY_EXTENSION, tt as relClassFor, ut as archivePathFor, vt as Importance, wt as TASK_STATUSES, xt as MemoryType, yt as MEMORY_TYPES } from "./dist-BCsav-EP.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { Config, Context, Effect, Layer, Result, Schedule, Schema } from "effect";
4
4
  import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
@@ -1673,7 +1673,7 @@ const articleFor = (spec) => {
1673
1673
  * ordinals a multiple of 180 apart state facts about different environments. It carries no digit, so
1674
1674
  * the numeric divergence family still compares exactly the quantity the claim asserts.
1675
1675
  */
1676
- const claimFor = (topic, type, ordinal) => {
1676
+ const claimFor$1 = (topic, type, ordinal) => {
1677
1677
  const noun = topic.nouns[nounIndexOf(ordinal, topic.nouns.length)] ?? "surface";
1678
1678
  const verb = topic.verbs[ordinal % topic.verbs.length] ?? "settles";
1679
1679
  const scope = scopeFor(ordinal);
@@ -1859,8 +1859,8 @@ const buildBase = (count, seed) => {
1859
1859
  const topic = TOPICS[ordinal % TOPICS.length];
1860
1860
  const type = TYPES[ordinal % TYPES.length];
1861
1861
  const day = 30 + ordinal % 240;
1862
- const claim = claimFor(topic, type, ordinal);
1863
- const title = titleFor$1(topic, type, ordinal);
1862
+ const claim = claimFor$1(topic, type, ordinal);
1863
+ const title = titleFor$2(topic, type, ordinal);
1864
1864
  const kit = KITS[ordinal % KITS.length];
1865
1865
  const jitter = next();
1866
1866
  specs.push({
@@ -1890,7 +1890,7 @@ const buildBase = (count, seed) => {
1890
1890
  * one subject. `title` is the first field of `fts_text`, and a title naming a different noun than the
1891
1891
  * claim would put a term into the lexical arm that the memory does not assert.
1892
1892
  */
1893
- const titleFor$1 = (topic, type, ordinal) => {
1893
+ const titleFor$2 = (topic, type, ordinal) => {
1894
1894
  const noun = topic.nouns[nounIndexOf(ordinal, topic.nouns.length)] ?? "surface";
1895
1895
  return `${topic.service} ${noun} ${scopeFor(ordinal)} ${type.replace("_", " ")} ${ordinal}`;
1896
1896
  };
@@ -3624,6 +3624,26 @@ const setMeta = (html, name, value) => {
3624
3624
  return offset === void 0 ? html : insertAt(html, offset, `${metaLine$1(name, value)}\n`);
3625
3625
  };
3626
3626
  /**
3627
+ * Append another `<meta>` of a repeatable name, a new `memhtml-entity` or `memhtml-tag`, after the
3628
+ * last one already present. Adding a value that is already there is a no-op, so the operation
3629
+ * is idempotent and a re-run of a sleep phase cannot grow the head.
3630
+ */
3631
+ const addMeta = (html, name, value) => {
3632
+ if (!isMemoryMetaName(name)) return html;
3633
+ const document = parseDocument(html);
3634
+ const head = headOf$1(document);
3635
+ if (head === void 0) return html;
3636
+ const present = memhtmlMetas(head).filter((meta) => meta.name === name);
3637
+ if (present.some((meta) => attr(meta.element, "content") === value)) return html;
3638
+ const last = present.at(-1);
3639
+ if (last !== void 0) {
3640
+ const span = spanOf(last.element);
3641
+ if (span !== void 0) return insertAt(html, lineEndAt(html, span.end), `${metaLine$1(name, value)}\n`);
3642
+ }
3643
+ const offset = insertionOffsetForMeta(html, head, name);
3644
+ return offset === void 0 ? html : insertAt(html, offset, `${metaLine$1(name, value)}\n`);
3645
+ };
3646
+ /**
3627
3647
  * Append a `<link rel="memhtml-…">` edge. Idempotent on the `(rel, href)` pair, because the sleep
3628
3648
  * conflict phase promotes the same corroborated edge on every run and a duplicated `<link>`
3629
3649
  * would become a duplicated `edges` row.
@@ -5998,7 +6018,7 @@ const MEMORY_BODY_BUDGET = 16e3;
5998
6018
  */
5999
6019
  const MAX_PER_ENTITY = 2;
6000
6020
  /** `arc` memories take {@link ARC_BODY_BUDGET}; everything else takes {@link MEMORY_BODY_BUDGET}. */
6001
- const budgetFor = (memoryType) => memoryType === "arc" ? ARC_BODY_BUDGET : MEMORY_BODY_BUDGET;
6021
+ const budgetFor$1 = (memoryType) => memoryType === "arc" ? ARC_BODY_BUDGET : MEMORY_BODY_BUDGET;
6002
6022
  /**
6003
6023
  * Fold ranked candidates into quotes and index lines under one character budget.
6004
6024
  *
@@ -7746,7 +7766,7 @@ const makeRetrieval = (deps) => {
7746
7766
  * every concrete memory behind it. The pack would then explain the pattern and cite none of the
7747
7767
  * evidence.
7748
7768
  */
7749
- const arcs = foldDisclosure(candidates.filter((candidate) => candidate.memoryType === "arc"), budgetFor("arc"));
7769
+ const arcs = foldDisclosure(candidates.filter((candidate) => candidate.memoryType === "arc"), budgetFor$1("arc"));
7750
7770
  const memories = foldDisclosure(candidates.filter((candidate) => candidate.memoryType !== "arc"), budget);
7751
7771
  return {
7752
7772
  arcs,
@@ -8949,12 +8969,21 @@ const batchCall = (model, label, request) => isolate(label, model.generateObject
8949
8969
  * `sleep_phases` row, and in a `--phases` flag, and three copies of the string would drift.
8950
8970
  */
8951
8971
  /**
8952
- * The fifteen phases, in execution order.
8972
+ * The sixteen phases, in execution order.
8953
8973
  *
8954
8974
  * The order encodes the predecessor memory system's dependencies (design §6): entity resolution precedes person
8955
8975
  * links so aliases have already merged, confidence decay precedes retention triage so triage
8956
8976
  * scores the decayed value, and dedup-merge precedes compress and retention because both operate
8957
8977
  * on the post-merge set.
8978
+ *
8979
+ * `task-detection` is sixteenth-in-list and thirteenth-in-order, sitting after `trace-consolidation`
8980
+ * and before `integrity`, and both edges are deliberate. It scans the ACTIVE corpus for unresolved
8981
+ * commitments, so it has to run after every phase that changes what is active — after dedup's folds,
8982
+ * after retention's evictions, after compress's canonicals, and after trace consolidation's newly
8983
+ * distilled memories, which are the freshest text of the night and the likeliest to carry one. And it
8984
+ * WRITES files, so it must precede `integrity`, which repairs dangling hrefs and regenerates the
8985
+ * directory artifacts: a task minted afterwards would be absent from its directory's `index.html`
8986
+ * until the next night.
8958
8987
  */
8959
8988
  const SLEEP_PHASES = [
8960
8989
  "preflight",
@@ -8969,6 +8998,7 @@ const SLEEP_PHASES = [
8969
8998
  "compress",
8970
8999
  "reprieve",
8971
9000
  "trace-consolidation",
9001
+ "task-detection",
8972
9002
  "integrity",
8973
9003
  "state-export",
8974
9004
  "report"
@@ -9116,6 +9146,18 @@ const meta = (name, value) => ({
9116
9146
  name,
9117
9147
  value
9118
9148
  });
9149
+ /**
9150
+ * An APPEND to a repeatable meta, as a value. `setMeta` writes the first value of a name and cannot
9151
+ * express a second, so a phase adding one more `memhtml-tag` needs this constructor.
9152
+ *
9153
+ * Idempotent, because `addMeta` returns the input unchanged when the value is already present — which is
9154
+ * what keeps a re-run of a phase that appends a tag free, the same property `stampFile` reads.
9155
+ */
9156
+ const addTag = (value) => ({
9157
+ kind: "addMeta",
9158
+ name: "memhtml-tag",
9159
+ value
9160
+ });
9119
9161
  /** A link addition, as a value. */
9120
9162
  const link = (rel, href) => ({
9121
9163
  kind: "addLink",
@@ -9135,6 +9177,7 @@ const unlink = (rel, href) => href === void 0 ? {
9135
9177
  const applyHeadEdits = (html, edits) => {
9136
9178
  let out = html;
9137
9179
  for (const edit of edits) if (edit.kind === "meta") out = setMeta(out, edit.name, edit.value);
9180
+ else if (edit.kind === "addMeta") out = addMeta(out, edit.name, edit.value);
9138
9181
  else if (edit.kind === "addLink") out = addLink(out, edit.rel, edit.href);
9139
9182
  else out = removeLink(out, edit.rel, edit.href);
9140
9183
  return out;
@@ -9179,12 +9222,43 @@ const stampFile = (env, path, edits) => Effect.gen(function* () {
9179
9222
  * `R100` similarity score. Rename similarity is computed tree-to-tree, so a head stamp in the same
9180
9223
  * commit lowers it (measured R059-R087 on real memory files). `originalPathFor` is the authoritative
9181
9224
  * inverse of the archive mapping, and no correctness path here reads the score.
9225
+ *
9226
+ * **The destination is PROBED, and a taken one gets an ordinal.** `git mv` onto a path that already
9227
+ * holds a file exits 128 (`fatal: destination exists`, measured 2026-08-19), which fails the whole
9228
+ * phase rather than the one file. That is reachable — not hypothetically — because one live path can be
9229
+ * archived TWICE INSIDE ONE YEAR, and the year is the only partition the mapping has:
9230
+ *
9231
+ * - A DETECTED task's path is deliberately deterministic (`tasks.ts`: the digest keys the finding, so a
9232
+ * finding restated tomorrow lands on the same path on purpose). Mint, sweep-close, the finding
9233
+ * reappears, sweep-close again — and the second close aims at the first close's archive path.
9234
+ * - Any path a human restores out of the archive and lets a later night evict again is the same shape
9235
+ * with no detector involved.
9236
+ *
9237
+ * So the fix is here rather than in `tasks.ts`: the collision is a property of the ARCHIVE mapping, and
9238
+ * every phase that archives — retention triage, reprieve, dedup's drops, compress's members — has the
9239
+ * same exposure. The ordinal is `-2`, `-3`, … at the first FREE candidate, deterministic given the tree,
9240
+ * and it goes through `withCollisionOrdinal` so it lands inside `SLUG_MAX_LENGTH` exactly as
9241
+ * `trace-consolidation`'s `freePath` and the store's own collision loop do.
9242
+ *
9243
+ * **A suffixed destination is no longer `originalPathFor`'s inverse, and that is the stated cost.** The
9244
+ * first archiving of a path keeps the unsuffixed name, so `integrity`'s dangling-href repair still finds
9245
+ * it and every existing inverse assertion holds; a SECOND archiving of one path is a second file whose
9246
+ * name says so. Nothing reads the inverse to decide a write — `integrity` searches a known-path set and
9247
+ * falls back to dropping the edge — so the alternative (failing the phase) is strictly worse.
9248
+ *
9249
+ * Exhausting the ordinals logs and answers `null`, which leaves the file at its live path. `null`
9250
+ * already means "not archived" to every caller, so an exhausted probe is counted as a file that did not
9251
+ * move rather than one that was destroyed.
9182
9252
  */
9183
9253
  const archiveFile = (env, path, extraEdits = []) => Effect.gen(function* () {
9184
9254
  const normalized = normalizePath(path);
9185
- const target = archivePathFor(normalized, yearOf(env.date));
9186
9255
  const html = yield* readFileBytes(env, normalized);
9187
9256
  if (html === void 0) return null;
9257
+ const target = yield* freeArchivePath(env, normalized);
9258
+ if (target === void 0) {
9259
+ yield* Effect.logWarning(`sleep.archive refused ${normalized}: every archive ordinal is taken, so the file stays live`);
9260
+ return null;
9261
+ }
9188
9262
  yield* attemptIo(`sleep.archive.mkdir:${target}`, async () => {
9189
9263
  const { mkdir } = await import("node:fs/promises");
9190
9264
  const { dirname } = await import("node:path");
@@ -9201,6 +9275,43 @@ const archiveFile = (env, path, extraEdits = []) => Effect.gen(function* () {
9201
9275
  yield* env.deps.git.add([target]);
9202
9276
  return target;
9203
9277
  });
9278
+ /**
9279
+ * Archive ordinals tried before a file is left where it is. The store's own ceiling and
9280
+ * `trace-consolidation`'s, verbatim, so the three collision loops in this repo agree on the number.
9281
+ */
9282
+ const ARCHIVE_ORDINAL_LIMIT = 1e3;
9283
+ /**
9284
+ * The lowest free archive path for a live path, or `undefined` when every ordinal is taken.
9285
+ *
9286
+ * DISK is the only authority, and it is enough: a phase archiving one path twice in one run cannot
9287
+ * happen, because the first `git mv` takes the file away and `archiveFile`'s own missing-source read
9288
+ * answers `null` for the second. So there is no in-run `claimed` set to union in, which is the one
9289
+ * half `trace-consolidation`'s `freePath` needs and this does not.
9290
+ */
9291
+ const freeArchivePath = (env, normalized) => Effect.gen(function* () {
9292
+ const year = yearOf(env.date);
9293
+ for (let ordinal = 1; ordinal <= ARCHIVE_ORDINAL_LIMIT; ordinal += 1) {
9294
+ const candidate = withArchiveOrdinal(archivePathFor(normalized, year), ordinal);
9295
+ if ((yield* readFileBytes(env, candidate)) === void 0) return candidate;
9296
+ }
9297
+ });
9298
+ /**
9299
+ * A path with a collision ordinal spliced into its filename STEM, before the extension.
9300
+ *
9301
+ * Before the extension rather than after, so the result is still an `.html` path the indexer reads and
9302
+ * the parser accepts. Ordinal 1 is the bare path, matching `withCollisionOrdinal`'s own convention, so
9303
+ * the ordinary single-archiving case produces byte-identical paths to what this function replaced.
9304
+ */
9305
+ const withArchiveOrdinal = (path, ordinal) => {
9306
+ if (ordinal <= 1) return path;
9307
+ const cut = path.lastIndexOf("/");
9308
+ const directory = path.slice(0, cut + 1);
9309
+ const filename = path.slice(cut + 1);
9310
+ const dot = filename.lastIndexOf(".");
9311
+ const stem = dot <= 0 ? filename : filename.slice(0, dot);
9312
+ const extension = dot <= 0 ? "" : filename.slice(dot);
9313
+ return `${directory}${withCollisionOrdinal(stem, ordinal)}${extension}`;
9314
+ };
9204
9315
  /** The calendar year an archive path partitions under, from the run's own injected date. */
9205
9316
  const yearOf = (date) => {
9206
9317
  const year = Number(date.slice(0, 4));
@@ -9257,7 +9368,15 @@ const DEFAULT_MODELS = {
9257
9368
  "edge-typing": "sonnet-5",
9258
9369
  "arc-synthesis": "opus-5",
9259
9370
  compress: "sonnet-5",
9260
- "trace-consolidation": "opus-5"
9371
+ "trace-consolidation": "opus-5",
9372
+ /**
9373
+ * Sonnet, for the same reason `dedup-merge` and the edge-typing judge name it: the question is an
9374
+ * extraction over text the model is shown — which of these memories carries an open commitment,
9375
+ * quote the sentence — and every consequence is decided afterwards by code. The sentence has to be
9376
+ * VERBATIM, which is copying rather than composing, and the confidence floor plus the verbatim
9377
+ * check are what a stronger model would otherwise be buying.
9378
+ */
9379
+ "task-detection": "sonnet-5"
9261
9380
  };
9262
9381
  /** The model a phase calls: the caller's override, else {@link DEFAULT_MODELS}, else sonnet. */
9263
9382
  const modelFor = (deps, phase) => deps.models?.[phase] ?? DEFAULT_MODELS[phase] ?? "sonnet-5";
@@ -9615,6 +9734,94 @@ than once, in different words.
9615
9734
  own dates afterwards, and a proposed group is still checked for contradicting claims, differing
9616
9735
  numbers, and differing product variants before anything is written. Answer only the question of
9617
9736
  sameness.`;
9737
+ /**
9738
+ * What a detected finding is: a commitment somebody made, or a follow-up nobody closed.
9739
+ *
9740
+ * Two values and not more. Issue #44 names both — "an open commitment or unresolved follow-up" — and
9741
+ * they are genuinely different work: a commitment has an actor who said they would do something, and a
9742
+ * follow-up is a question or a defect the text leaves open with nobody attached. The phase renders a
9743
+ * different claim for each, and the pair is closed because a third value would be a category whose
9744
+ * reading nothing downstream could state.
9745
+ */
9746
+ const TaskFindingKind = Schema.Literals(["commitment", "followup"]);
9747
+ /** One finding about one offered member. */
9748
+ const TaskFinding = Schema.Struct({
9749
+ /** The offered key, e.g. `m3`. A key the batch never held resolves to nothing and is dropped. */
9750
+ memberKey: Schema.String,
9751
+ /**
9752
+ * The sentence that carries the finding, copied VERBATIM from the member's text.
9753
+ *
9754
+ * Verbatim is a checked requirement and not a request: the phase looks the sentence up in the cited
9755
+ * file's own article text and refuses the mint when it is not there. So a paraphrase costs the
9756
+ * finding, which is why the system prompt says so in those words.
9757
+ */
9758
+ sentence: Schema.String,
9759
+ kind: TaskFindingKind,
9760
+ /** Unitless in `[0, 1]`. The mint gate is deterministic and reads this, not the prose. */
9761
+ confidence: Schema.Finite.check(Schema.isBetween({
9762
+ minimum: 0,
9763
+ maximum: 1
9764
+ }))
9765
+ });
9766
+ /**
9767
+ * One batch's whole answer: the findings across every member it was shown.
9768
+ *
9769
+ * `findings: []` is a refusal and the correct answer for most batches. Most memories record a fact and
9770
+ * carry no open work at all, and a model that felt obliged to fill the list would mint tasks out of
9771
+ * ordinary prose — which is precisely the noise the volume cap exists to bound and the reviewer's
9772
+ * attention cannot absorb.
9773
+ */
9774
+ const TaskDetection = Schema.Struct({ findings: Schema.Array(TaskFinding) });
9775
+ /**
9776
+ * The task-detection system prompt.
9777
+ *
9778
+ * The conservative posture every other judge in this file carries, aimed at the one thing this phase
9779
+ * can get wrong at scale: a memory that MENTIONS work is not a memory that carries an open
9780
+ * commitment. An `error_pattern` describing a defect somebody already fixed reads exactly like one
9781
+ * describing a defect nobody has, and the difference is in whether the text says it was resolved.
9782
+ *
9783
+ * The verbatim rule is stated as a consequence rather than as a style note, because it IS one: the
9784
+ * phase looks the sentence up in the file and drops the finding when it is absent.
9785
+ */
9786
+ const TASK_DETECT_SYSTEM = `You find OPEN WORK recorded in an AI agent's long-term memory system. You are given a NUMBERED LIST
9787
+ of memories. For each one, decide whether its text records work that is still open, and if so quote
9788
+ the sentence that says so.
9789
+
9790
+ Two kinds:
9791
+
9792
+ - commitment: somebody stated they would do something and the text does not say it happened. "I'll
9793
+ fix that tomorrow", "we need to wire capture before the next release", "leaving the merge until you
9794
+ review it".
9795
+ - followup: the text leaves something unresolved with nobody attached. An unfixed defect a memory
9796
+ describes, a question it ends on, a decision it says is blocked pending something else.
9797
+
9798
+ Rules:
9799
+
9800
+ - sentence must be copied VERBATIM from the member's own text, character for character. The system
9801
+ looks it up in the file and DISCARDS the finding when it is not found, so a paraphrase, a
9802
+ correction, a stitched-together sentence, or a summary loses the finding entirely.
9803
+ - Return findings: [] when nothing here carries open work. That is the ordinary answer: most memories
9804
+ record a fact, not a task. Refusing is correct whenever you are unsure.
9805
+ - A memory that DESCRIBES completed work is not open work. "we fixed the flaky teardown by pinning
9806
+ the port" is a record, not a task. Look for work the text leaves undone.
9807
+ - Never report a hypothetical, an option considered and rejected, or a general principle. "if the
9808
+ cache misses we would need to warm it" names no work anybody owes.
9809
+ - One finding per memory at most, and only for the memories that have one. Omitting a member is
9810
+ always allowed.
9811
+ - Rate confidence honestly. A finding above the floor becomes a task file a human is asked to review,
9812
+ and a queue full of things that were never work is a queue nobody reads.`;
9813
+ /** The instruction that closes a task-detection batch's user turn, after the member list. */
9814
+ const TASK_DETECT_INSTRUCTION = "Which of these memories carry open work? For each one that does, name it by its offered key, quote the sentence verbatim, say whether it is a commitment or a followup, and rate your confidence. Return findings: [] if none of them do.";
9815
+ /**
9816
+ * The task-detection user turn for one batch: every member's text under its offered key.
9817
+ *
9818
+ * `batchPrompt` from the kernel builds the list and appends the instruction, so
9819
+ * {@link TASK_DETECT_SYSTEM} plus the tool schema form the cache-eligible prefix and only the member
9820
+ * list is new bytes per batch. The label is `memory` rather than `member`, because what the model is
9821
+ * asked about is whether a MEMORY records open work, and the wrapper's label is the only place the
9822
+ * prompt names the thing.
9823
+ */
9824
+ const taskDetectPrompt = (members) => batchPrompt(members, TASK_DETECT_INSTRUCTION, { label: "memory" });
9618
9825
  /** One labelled corpus block, delimited so its prose cannot be read as an instruction. */
9619
9826
  const dataBlock = (label, text) => wrapAsData(label, text);
9620
9827
  /**
@@ -9698,7 +9905,7 @@ const dedupPrompt = (components) => {
9698
9905
  /**
9699
9906
  * The memory type no phase of a sleep cycle touches.
9700
9907
  *
9701
- * A task is live working state, and every one of the fifteen phases is a judgment about REMEMBERED
9908
+ * A task is live working state, and every one of the sixteen phases is a judgment about REMEMBERED
9702
9909
  * FACTS: decay says a claim is fading, dedup says two claims are one, edge typing says one claim
9703
9910
  * caused or contradicts another, retention says a claim has stopped earning its place. None of those hold for
9704
9911
  * a thing an agent intends to do, and each would be wrong applied to one. A task the agent has
@@ -9725,6 +9932,33 @@ const isSleepExcluded = (memoryType) => SLEEP_EXCLUDED_TYPES.includes(memoryType
9725
9932
  const activeCorpus = (db) => db.all(`SELECT path, memory_type, title, gist, body_text, content_hash, confidence, importance,
9726
9933
  word_count, created_at, updated_at, valid_until, reprieves
9727
9934
  FROM files WHERE archived = 0 ORDER BY created_at ASC, path ASC`);
9935
+ /**
9936
+ * The most recently touched active NON-TASK memories, newest first, capped.
9937
+ *
9938
+ * Task detection's candidate slice. Issue #44 asks for "recent/high-salience", and this is the RECENT
9939
+ * half alone, which is the deliberate cut. The salience half would mean a retention pass — label
9940
+ * propagation plus PageRank over the whole edge list plus the access plane — for a scan whose job is
9941
+ * to notice text nobody has resolved yet, and salience measures the opposite: how much a memory has
9942
+ * been leaned on since it was written. A commitment made last night has no access history at all, so
9943
+ * ranking by salience would systematically rank the phase's best candidates last.
9944
+ *
9945
+ * `updated_at DESC` and not `created_at`, because a memory CORRECTED yesterday carries yesterday's
9946
+ * text, which is the text a commitment would be in. `path ASC` breaks the tie, so the slice — and
9947
+ * therefore the batch boundaries and the `m1`..`mN` keys — is a function of the corpus rather than of
9948
+ * the order rows came back in.
9949
+ *
9950
+ * Tasks are excluded here, in the statement, matching every other read in this module. That is the
9951
+ * no-self-referential-loop guard issue #44 names, and putting it in SQL rather than in the phase means
9952
+ * a detected task cannot become evidence of another task even if a caller forgot to filter. The phase
9953
+ * carries a second, path-level check for the same invariant, because this one is keyed on a projected
9954
+ * column and the projection is refreshed once per night.
9955
+ */
9956
+ const recentActiveMemories = (db, options) => db.all(`SELECT path, memory_type, title, gist, body_text, content_hash, confidence, importance,
9957
+ word_count, created_at, updated_at, valid_until, reprieves
9958
+ FROM files
9959
+ WHERE archived = 0 AND memory_type NOT IN (${typePlaceholders()})
9960
+ ORDER BY updated_at DESC, path ASC
9961
+ LIMIT ?`, [...SLEEP_EXCLUDED_TYPES, options.limit]);
9728
9962
  /** A `memory_type NOT IN (…)` clause against alias `f`, or nothing when nothing is excluded. */
9729
9963
  const typeFilterFor = (alias, excluded) => excluded.length === 0 ? "" : ` AND ${alias}.memory_type NOT IN (${excluded.map(() => "?").join(", ")})`;
9730
9964
  /**
@@ -10803,6 +11037,609 @@ const confidenceDecay = (env) => Effect.gen(function* () {
10803
11037
  };
10804
11038
  });
10805
11039
 
11040
+ //#endregion
11041
+ //#region packages/sleep/dist/tasks.js
11042
+ /**
11043
+ * The minting discipline for a DETECTED task: one home for idempotence, evidence verification, the
11044
+ * nightly cap, and self-cleaning.
11045
+ *
11046
+ * Issue #44's shape is three detection surfaces sharing one discipline. The surfaces differ in what
11047
+ * they notice — a review band entity-resolution declined to merge, a near-duplicate pair the
11048
+ * divergence veto refused, a commitment a model found in a memory's own prose — and they agree on
11049
+ * everything that happens after: the finding becomes a `task` file authored `agent:sleep`, keyed on a
11050
+ * stable digest so a second night refreshes rather than duplicates, carrying its evidence verbatim,
11051
+ * inside a nightly volume cap, and closed when the finding stops appearing. That agreement is what
11052
+ * lives here. A copy of it per surface would be three chances to mint a task nobody can trust.
11053
+ *
11054
+ * ## A detected task is a `task`, and inherits every firewall by being one
11055
+ *
11056
+ * Nothing below teaches sleep about a new kind of file. `task` is already a memory type with its own
11057
+ * lifecycle (`todo/doing/blocked/done`, and `done` archives), its own edge class, its own placement
11058
+ * rule, and — the part that matters here — a standing exclusion from every sleep phase
11059
+ * (`SLEEP_EXCLUDED_TYPES`), from retrieval by default, from the salience arm, and from the
11060
+ * `files_content_hash_active` dedup index. So a file this module writes is invisible to dedup,
11061
+ * compress, retention, edge typing, entity resolution, and person links from the moment it lands,
11062
+ * with no phase needing to learn about it. That is the whole reason detection mints a task instead of
11063
+ * a new artifact class.
11064
+ *
11065
+ * ## The KEY is the path, not a meta and not the content hash
11066
+ *
11067
+ * A detected task's idempotence surface is its PATH: `areas/inbox/tasks/det-<12 hex>-<slug>.html`,
11068
+ * where the digest is {@link detectionKey} over the detector's name and a canonical finding string.
11069
+ * Three properties follow, and each is why the alternatives were declined:
11070
+ *
11071
+ * - **Not the content hash.** `files_content_hash_active` deliberately carves out open tasks — two
11072
+ * open tasks with identical bodies are two real work items — so the structural dedup key cannot
11073
+ * answer "have I already minted this finding". A detection that relied on it would mint a second
11074
+ * task every night and the index would admit every one of them.
11075
+ * - **Not a new `memhtml-detection` meta.** The meta vocabulary is CLOSED and ordered
11076
+ * (`packages/html/src/vocabulary.ts`), so a new name is a format change plus a parse change plus a
11077
+ * projection plus a migration plus an index, and the lookup it would buy is a lookup the path
11078
+ * already answers. A path also survives `rm index.db && rebuild` with no projection at all, which a
11079
+ * queryable column does not.
11080
+ * - **Collision-free by construction, at the LIVE path.** Every stem begins with a distinct digest, so
11081
+ * two different findings cannot land on one live path however their titles slug. This module therefore
11082
+ * needs none of the ordinal-suffix search `trace-consolidation`'s `freePath` performs, and cannot
11083
+ * silently overwrite a file the way that probe exists to prevent: a live path that is already taken is
11084
+ * BY DEFINITION the same finding, which is the refresh case rather than a collision.
11085
+ *
11086
+ * The ARCHIVE path is the one place that reasoning inverts, and `archiveFile` owns it. Determinism is
11087
+ * what makes a mint idempotent and is exactly what makes two archivings of one key collide, because
11088
+ * `archivePathFor` partitions only by YEAR: mint → sweep-close → the finding reappears → sweep-close
11089
+ * again lands both closures on one archive path inside one year, and `git mv` exits 128 on a
11090
+ * destination that exists. So the ordinal search lives in `edits.ts`, where every archiving phase gets
11091
+ * it, and the dismissal scan below is a PREFIX scan rather than a derived path because of it.
11092
+ *
11093
+ * ## The TREE is read, never the index
11094
+ *
11095
+ * Every lookup here is a `readdir` plus a file read under {@link DETECTED_TASK_DIR}. The index is
11096
+ * refreshed once, in preflight, and not again, so a task minted earlier in the same night is absent
11097
+ * from it — and two detectors reaching one finding in one night is exactly the case idempotence has
11098
+ * to cover. Reading the tree makes a mint see the mints before it. The cost is bounded by
11099
+ * {@link DETECTED_TASK_CAP} reads of one directory per phase, because self-cleaning keeps that
11100
+ * directory the size of the OPEN detected queue rather than of the corpus.
11101
+ *
11102
+ * ## A human's closure is a STANDING DISMISSAL, and it is durable
11103
+ *
11104
+ * A detected task a human closed is a proposal they ANSWERED. `done` archives, so the file leaves the
11105
+ * open queue — and the open queue was the only thing a mint used to consult, so the same finding minted
11106
+ * a fresh task the next night, and the night after, forever. There was no way to say no. That is worse
11107
+ * than noise: a queue whose items cannot be dismissed is a queue a human stops reading, which is the
11108
+ * failure {@link DETECTED_TASK_CAP} and the sweep both exist to prevent from the other direction.
11109
+ *
11110
+ * So {@link mintDetectedTask} also reads the ARCHIVE, and a `done`-stamped detected task under the same
11111
+ * key with NO {@link MACHINE_CLOSED_TAG} is a standing dismissal: the mint is declined and counted
11112
+ * `dismissed`. Three properties of the shape:
11113
+ *
11114
+ * - **The discriminator is a tag on the file, because nothing else told the two closures apart.** See
11115
+ * {@link MACHINE_CLOSED_TAG}. A SWEPT task must not read as a dismissal — the finding vanishing and a
11116
+ * human declining it are opposite facts, and a finding that reappears after vanishing is new
11117
+ * information a reviewer should see.
11118
+ * - **It is a PREFIX SCAN of the year buckets, not a derived path.** `archiveFile`'s ordinal suffixing
11119
+ * means one key can own `det-<key>-<slug>.html` and `det-<key>-<slug>-2.html` in one year, so there is
11120
+ * no single path to probe. The scan is a `readdir` per year over
11121
+ * `archive/<YYYY>/areas/inbox/tasks/`, the same TREE read `openDetections` performs, bounded by
11122
+ * {@link DISMISSAL_LOOKBACK_YEARS}.
11123
+ * - **Dismissal is durable within the archive's VISIBILITY.** A human who wants the review back deletes
11124
+ * the archived file (or moves it out of the year buckets), and the next night mints it again. Nothing
11125
+ * else undoes it, which is the point: a dismissal that expired on a timer would be a queue item that
11126
+ * came back for no reason a human could name.
11127
+ *
11128
+ * ## What the code verifies, and what it cannot
11129
+ *
11130
+ * A quote is checked against the cited file's own article text and a mint whose quote is not there is
11131
+ * REFUSED ({@link mintDetectedTask} answers `unverified`). That is the issue's "proposal with
11132
+ * evidence, never an assertion", enforced rather than asked for: the one detector whose evidence a
11133
+ * model supplies is surface 3, and a fabricated sentence must not reach a file a human then reads as
11134
+ * a citation.
11135
+ *
11136
+ * A MEASUREMENT is a different thing and {@link DetectionEvidence} says so in the type. The evidence
11137
+ * behind an entity-resolution review candidate is a character-similarity ratio and two file counts —
11138
+ * a fact about the corpus that no sentence anywhere states — so there is nothing to verify it
11139
+ * against, and pretending otherwise by quoting an arbitrary claiming memory would manufacture a
11140
+ * citation to satisfy a check. The union keeps the difference visible at every call site instead of
11141
+ * leaving it to convention.
11142
+ *
11143
+ * A SESSION is the third, and it is the one place issue #44's "body must quote its source verbatim"
11144
+ * is deliberately NOT satisfied, because a stronger invariant refuses it. Surface 2's evidence is a
11145
+ * transcript line, and `.memhtml` holds no session content: the trace plane is a read-only index over
11146
+ * `~/.claude/projects`, and `phases/trace-consolidation.ts` states — with a byte-level test behind it —
11147
+ * that a distilled claim reaches the corpus and its verbatim quote does not. A quote copied into a
11148
+ * task body would be exactly the leak that test exists to catch. So the `session` arm carries the
11149
+ * session ID and no quote: the ID becomes a `memhtml-session` stamp (a projected column, so
11150
+ * `files.session_id` answers "which session opened this"), the body names the session as the place to
11151
+ * look, and the verbatim line goes where every other trace-consolidation quote goes, into the COMMIT
11152
+ * MESSAGE, which is not indexed, not chunked, not embedded, and not retrievable.
11153
+ *
11154
+ * What verifies a `session` quote is therefore not code in this file but the client boundary:
11155
+ * `ungroundedCommitmentReason` (`apps/consolidator/src/contract.ts`) refuses the whole turn when a
11156
+ * commitment cites a session the run did not make readable, and the phase additionally drops a
11157
+ * commitment whose session is outside the batch it asked about. Re-verifying the quote against
11158
+ * transcript bytes here was considered and declined: it would require `MEMHTML_TRACE_ROOT` in
11159
+ * `PhaseEnv`, which `consolidator.ts` records as the thing deliberately kept out of the environment
11160
+ * all sixteen phases share, and it would buy a check against a file that may have rotated away since
11161
+ * the consolidator read it.
11162
+ */
11163
+ /**
11164
+ * Where a detected task lands: the ordinary task placement, asked of `@memhtml/contracts` rather than
11165
+ * retyped.
11166
+ *
11167
+ * A detected task is an ordinary task and files where one files. `placementFor` routes a workspaceless
11168
+ * task to `areas/inbox/tasks`, and `memhtml doctor` reports inbox depth, so a noisy detector shows up
11169
+ * as a health signal on the surface built to carry one. A parallel `detected/` tree would be a second
11170
+ * place to look for the same work and would sit outside the four PARA buckets the indexer reads.
11171
+ */
11172
+ const DETECTED_TASK_DIR = placementFor({ memoryType: "task" });
11173
+ /** The filename prefix that makes a detected task recognizable from its path alone. */
11174
+ const DETECTION_PREFIX = "det-";
11175
+ /**
11176
+ * Hex characters of the digest carried in a path.
11177
+ *
11178
+ * Twelve, which is git's own abbreviated-sha width and the same width the report renderer prints. It
11179
+ * leaves 48 bits against a queue whose size the cap and the sweep hold in the tens, and it costs 17
11180
+ * characters of the 80-character slug budget rather than 68.
11181
+ */
11182
+ const DETECTION_DIGEST_CHARS = 12;
11183
+ /** The tag every detected task carries first, so `task list` can filter the machine's queue. */
11184
+ const DETECTED_TAG = "detected";
11185
+ /**
11186
+ * The tag a MACHINE closure appends, which is the only thing that tells one apart from a human's.
11187
+ *
11188
+ * **It had to be added, because nothing in a file distinguished the two.** Both closure paths write
11189
+ * exactly `memhtml-task-status: done` plus the three archive stamps — `closeVanishedDetections` here and
11190
+ * `memhtml task status done` in `apps/cli/src/operations.ts`, deliberately, so a detected task closes the
11191
+ * same way a human's does. The differing REASON ("no longer detected" against "task done") lives only in
11192
+ * the commit message, and a commit message is not a queryable fact about a file: reading it would mean a
11193
+ * `git log --follow` per archived path per mint, and it says nothing at all after a rebase or a
11194
+ * `git filter-repo`. So the discriminator is a `memhtml-tag`, which is already repeatable, already in the
11195
+ * closed vocabulary, already projects to `file_tags`, and travels with the file's bytes.
11196
+ *
11197
+ * The value of the distinction is {@link mintDetectedTask}'s dismissal check. A HUMAN closing a detected
11198
+ * task is answering the proposal — "I looked, and I do not want this review" — and re-minting it the next
11199
+ * night makes the queue un-dismissable. A MACHINE closing it means the finding stopped appearing or the
11200
+ * system resolved it, and a finding that comes back later is genuinely new information. Without the tag
11201
+ * the two are one archived `done` task, and the check would have to pick one reading and be wrong about
11202
+ * the other half.
11203
+ *
11204
+ * Appended, never set, so it lands AFTER the detector tag and {@link openDetections}' positional read of
11205
+ * `tags[0]`/`tags[1]` is untouched.
11206
+ */
11207
+ const MACHINE_CLOSED_TAG = "machine-closed";
11208
+ /**
11209
+ * Archive year partitions searched for a standing dismissal.
11210
+ *
11211
+ * Ten, the same span `integrity`'s `ARCHIVE_LOOKBACK_YEARS` chases a dangling href over, and stated here
11212
+ * rather than imported because a module may not depend on a phase. The number is what bounds a
11213
+ * dismissal's DURABILITY: a task dismissed eleven years ago can be re-minted, which is the honest
11214
+ * reading of "durable within the archive's visibility" rather than a promise the code cannot keep.
11215
+ */
11216
+ const DISMISSAL_LOOKBACK_YEARS = 10;
11217
+ /**
11218
+ * Detected tasks one night may mint, across every detector.
11219
+ *
11220
+ * Ten, from issue #44 verbatim: "a noisy detector that mints 200 tasks destroys the working set it
11221
+ * exists to serve". The budget is SHARED rather than per-detector, because the number a human can
11222
+ * review is a property of the human and not of how many detectors sleep happens to run. Overflow is
11223
+ * counted, never silently dropped — a detector pressing against the cap every night is a detector
11224
+ * whose threshold is wrong, and that is only visible in the counts.
11225
+ *
11226
+ * A REFRESH costs nothing. The cap bounds new work arriving in the queue, and re-stamping a task a
11227
+ * human has already been shown adds none.
11228
+ */
11229
+ const DETECTED_TASK_CAP = 10;
11230
+ /** Characters of a detected task's title. The same 90 a distilled memory's title is cut to. */
11231
+ const TITLE_CHARS = 90;
11232
+ /**
11233
+ * The slug budget left for a title once the prefix and digest are spent.
11234
+ *
11235
+ * Derived, not chosen, so the two cannot drift into a stem that breaches `SLUG_MAX_LENGTH` — which
11236
+ * `isSlug` rejects, and every other path in the corpus satisfies it.
11237
+ */
11238
+ const STEM_SLUG_CHARS = 80 - 4 - 12 - 1;
11239
+ /**
11240
+ * A finding's stable key: `det-<12 hex>` over the detector's name and a canonical finding string.
11241
+ *
11242
+ * The finding string is the CALLER's canonical form of what it noticed — an entity type plus two
11243
+ * sorted names, two sorted paths, a rel plus two sorted paths — and sorting is the caller's job
11244
+ * because only the caller knows which of its fields are unordered. What this adds is the
11245
+ * normalization every caller would otherwise repeat: NFC, lowercase, collapsed whitespace. So a
11246
+ * finding restated with different spacing on a later night keys the same, and a night that saw
11247
+ * `(a, b)` keys with a night that saw `(b, a)` provided the caller sorted.
11248
+ *
11249
+ * The detector's name is INSIDE the digest, so two detectors that happen to canonicalize one finding
11250
+ * identically still own separate tasks. They noticed different things about it, and a sweep is
11251
+ * per-detector: sharing a key would let one detector's sweep close the other's task.
11252
+ */
11253
+ const detectionKey = (detector, finding) => `${DETECTION_PREFIX}${createHash("sha256").update(`${normalizeFinding(detector)}${normalizeFinding(finding)}`, "utf8").digest("hex").slice(0, 12)}`;
11254
+ /** NFC, lowercase, collapsed whitespace, trimmed. The pre-digest form. */
11255
+ const normalizeFinding = (text) => text.normalize("NFC").toLowerCase().replace(/\s+/g, " ").trim();
11256
+ /** The path a key and a title name. Total, and unique per key whatever the title slugs to. */
11257
+ const detectedTaskPath = (key, title) => {
11258
+ const stem = slugify(title).slice(0, STEM_SLUG_CHARS).replace(/-+$/, "");
11259
+ return `${DETECTED_TASK_DIR}/${key}-${stem === "" ? SLUG_FALLBACK : stem}.html`;
11260
+ };
11261
+ /** The key a detected task's path carries, or `undefined` when the path is not one. */
11262
+ const detectionKeyOf = (path) => {
11263
+ const filename = path.slice(path.lastIndexOf("/") + 1);
11264
+ return new RegExp(`^(${DETECTION_PREFIX}[0-9a-f]{${String(12)}})-`).exec(filename)?.[1];
11265
+ };
11266
+ /** True when a path is a detected task's. The self-scan guard surface-3 reads. */
11267
+ const isDetectedTaskPath = (path) => detectionKeyOf(path) !== void 0;
11268
+ /** A fresh budget at the nightly cap. */
11269
+ const makeDetectionBudget = (cap = 10) => ({
11270
+ remaining: Math.max(0, Math.trunc(cap)),
11271
+ overflow: 0
11272
+ });
11273
+ /**
11274
+ * The run's shared budget, or a fresh one when the run did not supply it.
11275
+ *
11276
+ * `PhaseEnv.detectionBudget` is optional so the five existing construction sites keep compiling, and
11277
+ * the fallback is what makes a phase driven directly by a test behave like one inside a run: it gets
11278
+ * the full cap to itself. A caller must call this ONCE per phase invocation and thread the result,
11279
+ * because calling it per mint against an absent field would hand out a fresh cap every time.
11280
+ */
11281
+ const budgetFor = (env) => env.detectionBudget ?? makeDetectionBudget();
11282
+ /**
11283
+ * Mint a detected task, or refresh the one this finding already owns.
11284
+ *
11285
+ * Order matters and is stated once here, because each step's position is what makes it mean
11286
+ * something:
11287
+ *
11288
+ * 1. **The existing open detections are read from the tree.** A key already present is the refresh
11289
+ * case, and refreshing costs no budget.
11290
+ * 2. **The archive is read for a standing dismissal**, and a human's closure of this key declines the
11291
+ * mint. Second rather than first because the open queue is one `readdir` and this is up to
11292
+ * {@link DISMISSAL_LOOKBACK_YEARS} of them, so the common case — the key is open, refresh it — pays
11293
+ * for none of them. See the module header for what makes a dismissal durable.
11294
+ * 3. **The evidence is verified before anything is written.** A quote absent from the file it cites
11295
+ * refuses the whole mint. Checking after the write would leave a task in the tree asserting a
11296
+ * citation the corpus does not support, and a later commit removing it would still be in the log.
11297
+ * 4. **The frame-key check runs against the OPEN queue only.** Two detectors describing one work
11298
+ * item in different words key differently, so the digest cannot catch them; a shared claim slot
11299
+ * can. It fires rarely by construction, because `frameKeyOf`'s guards fail closed on ordinary
11300
+ * prose — which is the honest scope of this check and the reason it is the second net rather than
11301
+ * the first. **Every caller's claim must be frame-DISTINCT per finding**: a claim whose frame key is
11302
+ * a constant makes this check cap that detector's whole queue at one task, which is the defect the
11303
+ * five minters' claim shapes are now measured against.
11304
+ * 5. **The budget is spent last**, so a refusal at any earlier step does not consume a night's
11305
+ * allowance.
11306
+ *
11307
+ * Staging only. The phase that called this commits, so a detected task lands in the SAME commit as
11308
+ * the work that found it, which is what puts it behind the discrimination gate and what makes the
11309
+ * commit reviewable as one decision.
11310
+ */
11311
+ const mintDetectedTask = (env, budget, request) => Effect.gen(function* () {
11312
+ const key = detectionKey(request.detector, request.finding);
11313
+ const open = yield* openDetections(env);
11314
+ const existing = open.find((detected) => detected.key === key);
11315
+ if (existing !== void 0) {
11316
+ /**
11317
+ * A second night refreshes the stamp and writes nothing else. Not the claim, not the evidence,
11318
+ * not the detail: a human may have edited the body or moved the status to `doing`, and a
11319
+ * detector overwriting that would take the queue away from the person it exists to serve.
11320
+ * `stampFile` returns false when the instant is already stamped, so a same-date re-run is free.
11321
+ */
11322
+ yield* stampFile(env, existing.path, [meta("memhtml-updated", env.at)]);
11323
+ return "refreshed";
11324
+ }
11325
+ if (yield* humanDismissed(env, key)) {
11326
+ yield* Effect.logInfo(`sleep.tasks ${request.detector} declined a mint: a human closed ${key} and the dismissal stands`);
11327
+ return "dismissed";
11328
+ }
11329
+ if (!(yield* evidenceHolds(env, request.evidence))) {
11330
+ yield* Effect.logWarning(`sleep.tasks ${request.detector} refused a mint: evidence not found in the source it cites`);
11331
+ return "unverified";
11332
+ }
11333
+ const frame = frameKeyOf(request.claim);
11334
+ if (frame !== null && open.some((detected) => frameKeyOf(detected.claim) === frame)) return "framed";
11335
+ if (budget.remaining <= 0) {
11336
+ budget.overflow += 1;
11337
+ return "capped";
11338
+ }
11339
+ const title = titleOf(request.title);
11340
+ const path = detectedTaskPath(key, title);
11341
+ yield* writeFileBytes(env, path, renderTemplate({
11342
+ title,
11343
+ /**
11344
+ * `claim` is required by `NewMemoryInput` and UNREAD when `articleHtml` is present, since
11345
+ * `articleHtmlFor` returns the pre-authored markup verbatim. It is stated anyway rather than
11346
+ * stubbed, so the two never disagree about what this file's claim is: {@link detectedArticle}
11347
+ * builds the `<mark>` from this same value.
11348
+ */
11349
+ claim: request.claim,
11350
+ articleHtml: detectedArticle(env, request),
11351
+ memoryType: "task",
11352
+ taskStatus: "todo",
11353
+ at: env.at,
11354
+ /**
11355
+ * `agent:sleep`, which is the author separation issue #44 asks for: a human's queue and the
11356
+ * machine's are told apart by `memhtml-author` rather than by where they sit, so both live in
11357
+ * one list and `task list` can filter.
11358
+ */
11359
+ author: "agent:sleep",
11360
+ /**
11361
+ * The generic tag first and the detector second, so the pair reads as "detected, by this".
11362
+ * The ORDER is load-bearing: `memhtml-tag` is repeatable, the serializer emits repeatables in
11363
+ * the order given, and {@link openDetections} reads the detector back off the second value.
11364
+ */
11365
+ tags: [DETECTED_TAG, request.detector],
11366
+ /**
11367
+ * `from_session` provenance, per issue #44, as the ordinary `memhtml-session` meta rather than
11368
+ * anything new. It is already in the closed vocabulary, already projects to
11369
+ * `files.session_id`, and already carries exactly this meaning on a memory an agent wrote
11370
+ * during a session — so a detected task minted from a transcript answers "which session is
11371
+ * this from" through the same column every other provenance query reads. Only the `session`
11372
+ * evidence arm has one; a measurement and a corpus quote are not from a session, and stamping
11373
+ * the run's id there would make the column mean two things.
11374
+ */
11375
+ ...request.evidence.kind === "session" ? { sessionId: request.evidence.sessionId.trim() } : {},
11376
+ ...dueOf(request.dueHint) === void 0 ? {} : { dueAt: dueOf(request.dueHint) }
11377
+ }));
11378
+ yield* env.deps.git.add([path]);
11379
+ budget.remaining -= 1;
11380
+ return "minted";
11381
+ });
11382
+ /**
11383
+ * Close every open detection of one detector whose key is not in `liveKeys`: stamp `done` and archive.
11384
+ *
11385
+ * Self-cleaning, per issue #44: "a finding that stops appearing closes its task with reason
11386
+ * `no longer detected`". A queue that only grows is a queue a human abandons, and the finding is the
11387
+ * only thing that can say a review is no longer wanted — the human declining to act on it cannot,
11388
+ * because that is indistinguishable from not having got to it yet.
11389
+ *
11390
+ * **`done` plus archive, matching `memhtml task status done` exactly.** `apps/cli/src/operations.ts`
11391
+ * stamps the status and then routes through `store.archiveMemory`, and this does the same two things
11392
+ * through sleep's staging discipline instead of through the store's own commit: the stamp goes on
11393
+ * first so it travels with the `git mv`, and `archiveFile` re-writes the stamped bytes at the
11394
+ * destination. `done` is not a resting state on its own; the archive tree plus `git log` is what
11395
+ * answers "what did I close".
11396
+ *
11397
+ * **The prose reason lives in the commit; the MACHINE/HUMAN distinction lives in a tag.** There is no
11398
+ * `memhtml-*` name for a closing reason and the vocabulary is closed, so the caller's `commitPhase` body
11399
+ * carries `no longer detected` — which is also where `store.archiveMemory` puts its own reason. But that
11400
+ * text is not readable as a fact about the file, and {@link mintDetectedTask}'s dismissal check needs one
11401
+ * bit of it: was this closed by the system or by a person. So every closure here also appends
11402
+ * {@link MACHINE_CLOSED_TAG}, which makes a swept task re-mintable when its finding comes back while a
11403
+ * human's closure stands. See {@link MACHINE_CLOSED_TAG}.
11404
+ *
11405
+ * **`liveKeys` must be every finding the detector SAW, not every finding it minted.** A finding
11406
+ * turned away by the cap is still live, and closing its task because a busy night declined to
11407
+ * refresh it would delete a real review the moment the queue got full. A finding below a phase's own
11408
+ * CONFIDENCE floor was also seen, so it belongs there too.
11409
+ *
11410
+ * **Call this only on the night's full-strength path.** A phase that degraded — no model bound, a
11411
+ * batch whose call failed, an early return before its scan finished — did not evaluate the candidate
11412
+ * set, so its `liveKeys` describes what it managed to look at rather than what exists. Sweeping there
11413
+ * would close a human's queue every credential-free night. Each caller states the condition it
11414
+ * sweeps under, and a caller whose degraded arm mines at a DIFFERENT floor than its full-strength one
11415
+ * is degraded even when no call failed — `dedup-merge`'s no-model arm records that reading.
11416
+ */
11417
+ const closeVanishedDetections = (env, detector, liveKeys) => Effect.gen(function* () {
11418
+ const open = yield* openDetections(env);
11419
+ let closed = 0;
11420
+ for (const detected of open) {
11421
+ if (detected.detector !== detector) continue;
11422
+ if (liveKeys.has(detected.key)) continue;
11423
+ yield* stampFile(env, detected.path, [
11424
+ meta("memhtml-task-status", "done"),
11425
+ meta("memhtml-updated", env.at),
11426
+ addTag(MACHINE_CLOSED_TAG)
11427
+ ]);
11428
+ if ((yield* archiveFile(env, detected.path)) !== null) closed += 1;
11429
+ }
11430
+ return closed;
11431
+ });
11432
+ /**
11433
+ * Close ONE detected task by path: stamp `done` and archive, exactly as {@link closeVanishedDetections}
11434
+ * does per file. Answers `false` and writes nothing when the path is not a detected task's.
11435
+ *
11436
+ * The refusal is the point, and it is a HARD guard rather than a convention. Surface 2 closes a task
11437
+ * because a transcript says the work is done, which is a model's reading of somebody's prose — so this
11438
+ * is the one closure path whose trigger is not a fact the corpus can check. A human-opened task closed
11439
+ * on that basis is work silently taken out of somebody's queue by a sentence they did not write, and
11440
+ * `done` ARCHIVES, so the file also leaves the directory they look in. {@link isDetectedTaskPath} is
11441
+ * the discriminator because it reads the PATH: it needs no parse, no index row, and no meta, so it
11442
+ * cannot be defeated by a file whose head a model influenced.
11443
+ *
11444
+ * A caller that found its path through {@link openDetections} is already inside the guard, since that
11445
+ * function only returns detected paths. The check runs anyway, here, at the write: a second caller
11446
+ * arriving with a path from a query, a report, or a match on a title is the case this exists for, and a
11447
+ * guard that lived at the lookup instead would not cover it.
11448
+ *
11449
+ * The closing REASON goes in the caller's commit body, for the reason
11450
+ * {@link closeVanishedDetections} records: there is no `memhtml-*` name for it and the vocabulary is
11451
+ * closed. {@link MACHINE_CLOSED_TAG} rides on the file here too, and it is if anything MORE load-bearing
11452
+ * on this path: a completion detected in a transcript is the machine's reading, so if the commitment is
11453
+ * restated on a later night the task must be re-mintable rather than read as a human's dismissal.
11454
+ */
11455
+ const closeDetectedTask = (env, path) => Effect.gen(function* () {
11456
+ if (!isDetectedTaskPath(path)) {
11457
+ yield* Effect.logWarning(`sleep.tasks refused to close ${path}: not a detected task, so no detector may close it`);
11458
+ return false;
11459
+ }
11460
+ yield* stampFile(env, path, [
11461
+ meta("memhtml-task-status", "done"),
11462
+ meta("memhtml-updated", env.at),
11463
+ addTag(MACHINE_CLOSED_TAG)
11464
+ ]);
11465
+ return (yield* archiveFile(env, path)) !== null;
11466
+ });
11467
+ /**
11468
+ * Every OPEN detected task, read from the tree, path-ordered.
11469
+ *
11470
+ * Open means present under {@link DETECTED_TASK_DIR} and not stamped `done`. Both halves are needed
11471
+ * and neither is redundant: closing archives the file out of the directory, so presence is almost
11472
+ * sufficient — but a human may stamp `done` by hand through `memhtml task status`, or a run may be
11473
+ * interrupted between the stamp and the `git mv`, and a detector must not refresh or re-close a task
11474
+ * somebody already finished.
11475
+ *
11476
+ * **Parsed, not scanned for meta lines.** `memhtml-tag` is repeatable and the surgical `readMeta`
11477
+ * returns only the first value of a name, so the detector tag is unreachable without the real parser
11478
+ * — the same reason `entity-resolution`'s alias oracle parses person files. A file that does not
11479
+ * parse is skipped: it is not indexed either, so no detection is keyed on it.
11480
+ */
11481
+ const openDetections = (env) => Effect.gen(function* () {
11482
+ const filenames = yield* detectedFilenames(env, DETECTED_TASK_DIR);
11483
+ const out = [];
11484
+ for (const filename of filenames) {
11485
+ const path = `${DETECTED_TASK_DIR}/${filename}`;
11486
+ const key = detectionKeyOf(path);
11487
+ if (key === void 0) continue;
11488
+ const html = yield* readFileBytes(env, path);
11489
+ if (html === void 0) continue;
11490
+ const doc = yield* parseMemory(html).pipe(Effect.orElseSucceed(() => void 0));
11491
+ if (doc === void 0) continue;
11492
+ if (doc.metas.memoryType !== "task" || doc.metas.taskStatus === "done") continue;
11493
+ const [first, second] = doc.tags;
11494
+ if (first !== "detected" || second === void 0) continue;
11495
+ out.push({
11496
+ path,
11497
+ key,
11498
+ detector: second,
11499
+ title: doc.title,
11500
+ claim: doc.article.gist
11501
+ });
11502
+ }
11503
+ return out;
11504
+ });
11505
+ /**
11506
+ * True when a HUMAN closed a detected task of this key and the archive still holds it: a standing
11507
+ * dismissal. See the module header for what makes it durable and how a human takes it back.
11508
+ *
11509
+ * A match requires all four, and each rules out a different false positive:
11510
+ *
11511
+ * - the filename carries this exact key, which is a prefix match on the stem rather than one derived
11512
+ * path, because `archiveFile`'s ordinal suffixing lets one key own several archived files;
11513
+ * - the file parses as a `task` stamped `done`, so an archived task somebody left `todo` (a file moved
11514
+ * by hand, or a run interrupted between the stamp and the `git mv`) is not read as an answer;
11515
+ * - the first tag is {@link DETECTED_TAG}, so a hand-written task that happened to land on a `det-` name
11516
+ * cannot dismiss a detector's finding;
11517
+ * - and {@link MACHINE_CLOSED_TAG} is ABSENT, which is the whole discriminator: a swept task carries it
11518
+ * and must stay re-mintable.
11519
+ *
11520
+ * The scan STOPS at the first match, so the common case on a corpus with archives costs one `readdir`
11521
+ * of the current year. Every year is a separate `readdir` and a missing one is empty, so a corpus with no
11522
+ * archive at all pays {@link DISMISSAL_LOOKBACK_YEARS} ENOENTs and reads no file.
11523
+ */
11524
+ const humanDismissed = (env, key) => Effect.gen(function* () {
11525
+ const year = yearOf(env.date);
11526
+ for (let back = 0; back <= 10; back += 1) {
11527
+ const directory = archivePathFor(DETECTED_TASK_DIR, year - back);
11528
+ for (const filename of yield* detectedFilenames(env, directory)) {
11529
+ if (!filename.startsWith(`${key}-`)) continue;
11530
+ const html = yield* readFileBytes(env, `${directory}/${filename}`);
11531
+ if (html === void 0) continue;
11532
+ const doc = yield* parseMemory(html).pipe(Effect.orElseSucceed(() => void 0));
11533
+ if (doc === void 0) continue;
11534
+ if (doc.metas.memoryType !== "task" || doc.metas.taskStatus !== "done") continue;
11535
+ if (doc.tags[0] !== "detected") continue;
11536
+ if (doc.tags.includes("machine-closed")) continue;
11537
+ return true;
11538
+ }
11539
+ }
11540
+ return false;
11541
+ });
11542
+ /**
11543
+ * The `.html` filenames under one directory carrying the detection prefix, sorted.
11544
+ *
11545
+ * A missing directory is `[]`, not a failure: a corpus that has never had a detected task has no
11546
+ * `areas/inbox/tasks` at all, and that is the state every first night starts from. It is the normal case
11547
+ * for the archive year buckets too, where most of the lookback window will never exist. Any OTHER errno
11548
+ * still fails, because a permission error on the queue directory is a real fault that must not read
11549
+ * as an empty queue and take a sweep through every open detection.
11550
+ */
11551
+ const detectedFilenames = (env, directory) => attemptIo(`sleep.tasks.list:${directory}`, async () => {
11552
+ const { readdir } = await import("node:fs/promises");
11553
+ try {
11554
+ return (await readdir(absoluteIn(env, directory))).filter((name) => name.startsWith("det-") && name.endsWith(".html")).sort();
11555
+ } catch (cause) {
11556
+ if (cause.code === "ENOENT") return [];
11557
+ throw cause;
11558
+ }
11559
+ });
11560
+ /**
11561
+ * True when the evidence is admissible: a quote only when the cited file's own article text carries it,
11562
+ * a measurement or a session citation whenever it is non-empty.
11563
+ *
11564
+ * Compared with whitespace collapsed on BOTH sides, and case-sensitively. Whitespace is not content
11565
+ * here — the same sentence read out of a `body_text` projection, out of a re-wrapped paragraph, and
11566
+ * out of the file's markup differ only in spacing, and refusing on that would refuse true quotes.
11567
+ * Case IS content: "the deploy is safe" and "the deploy is SAFE" are the same words and a citation
11568
+ * that changed the emphasis is not verbatim.
11569
+ *
11570
+ * The check reads the FILE, not the index row the caller found the sentence in. The tree is the
11571
+ * system of record and the index is refreshed once per night, so a row can name text an earlier
11572
+ * phase's commit has already replaced. A missing file refuses, which is the same posture every
11573
+ * other phase takes toward a path the tree no longer holds.
11574
+ *
11575
+ * A `session` citation has no file to read and this function says so rather than pretending to check
11576
+ * one. What stands behind it is `ungroundedCommitmentReason` at the client boundary plus the phase's
11577
+ * own batch-membership check; the module header records why re-reading the transcript here was
11578
+ * declined. The non-empty test is not the guard, it is the same floor the other two arms carry.
11579
+ */
11580
+ const evidenceHolds = (env, evidence) => Effect.gen(function* () {
11581
+ if (evidence.kind === "measurement") return evidence.detail.trim() !== "";
11582
+ if (evidence.kind === "session") return evidence.sessionId.trim() !== "" && evidence.statement.trim() !== "";
11583
+ const quote = flatten(evidence.quote);
11584
+ if (quote === "") return false;
11585
+ const html = yield* readFileBytes(env, evidence.sourcePath);
11586
+ if (html === void 0) return false;
11587
+ const doc = yield* parseMemory(html).pipe(Effect.orElseSucceed(() => void 0));
11588
+ if (doc === void 0) return false;
11589
+ return flatten(doc.article.bodyText).includes(quote);
11590
+ });
11591
+ /** Whitespace collapsed to single spaces and trimmed. The comparison form for a quote. */
11592
+ const flatten = (text) => text.replace(/\s+/g, " ").trim();
11593
+ /** A title: one line, sentence punctuation kept, cut to {@link TITLE_CHARS}. */
11594
+ const titleOf = (title) => flatten(title).slice(0, TITLE_CHARS).trim();
11595
+ /**
11596
+ * A due hint the format accepts, or `undefined`.
11597
+ *
11598
+ * `memhtml-due` is compared and ordered AS A STRING by the overdue query, so a value that does not
11599
+ * sort alongside the others would corrupt it. `isValidDatetime` is the format's own predicate, so a
11600
+ * hint a model supplied is dropped rather than written and the task simply has no due date.
11601
+ */
11602
+ const dueOf = (hint) => hint !== void 0 && isValidDatetime(hint.trim()) ? hint.trim() : void 0;
11603
+ /**
11604
+ * A detected task's article: the claim, the detail, the evidence, and the provenance line.
11605
+ *
11606
+ * Authored as MARKUP rather than through the template's prose path, because the evidence needs
11607
+ * `<q cite>` — the vocabulary's own quotation element, which carries its source URI and projects into
11608
+ * `file_citations(text, href)`. That is what makes issue #44's "the parser can verify the quote still
11609
+ * exists in the cited source" a single query rather than a re-read of every task. `<blockquote>` is
11610
+ * NOT in the closed vocabulary, so the quote is inline in its own paragraph.
11611
+ *
11612
+ * Using `articleHtml` means this function owns constraint 1, so the `<mark>` is placed in the first
11613
+ * `<p>` here and nowhere else. Every interpolation goes through `escapeText`/`escapeAttribute`: a
11614
+ * model-supplied sentence reaches this string on surface 3, and the source path reaches it on all of
11615
+ * them.
11616
+ */
11617
+ const detectedArticle = (env, request) => {
11618
+ const paragraphs = [`<p><mark>${escapeText(flatten(request.claim))}</mark></p>`];
11619
+ if (request.detail !== void 0 && request.detail.trim() !== "") paragraphs.push(`<p>${escapeText(flatten(request.detail))}</p>`);
11620
+ paragraphs.push(evidenceParagraph(request.evidence));
11621
+ paragraphs.push(`<p>Detected by <code>${escapeText(request.detector)}</code> on run <code>${escapeText(env.runId)}</code>. This is a proposal for a human to decide, not a finding the corpus asserts. It closes itself when the detector stops seeing it.</p>`);
11622
+ return paragraphs.join("\n");
11623
+ };
11624
+ /**
11625
+ * The one paragraph that states what the finding rests on, per evidence kind.
11626
+ *
11627
+ * Split out of {@link detectedArticle} once the third arm arrived, so the three readings sit beside
11628
+ * each other and the difference between them is legible. Each says out loud what a reader can do with
11629
+ * it: open the file and find the sentence, take the number on the corpus's word, or go back to the
11630
+ * session and read the line in the commit that opened this.
11631
+ *
11632
+ * The `session` arm carries NO quote, which is the trace-plane invariant and not an omission. See the
11633
+ * module header. It also carries no `<q cite>`, because there is nothing to cite: a session is not a
11634
+ * corpus path and `hrefFor` over an id would produce a link that resolves nowhere, which
11635
+ * `integrity`'s dangling-edge repair exists to prevent.
11636
+ */
11637
+ const evidenceParagraph = (evidence) => {
11638
+ if (evidence.kind === "quote") return `<p>Evidence, verbatim from <code>${escapeText(evidence.sourcePath)}</code>: <q cite="${escapeAttribute(hrefFor(evidence.sourcePath))}">${escapeText(flatten(evidence.quote))}</q></p>`;
11639
+ if (evidence.kind === "session") return `<p>Evidence, from session <code>${escapeText(evidence.sessionId)}</code>: ${escapeText(flatten(evidence.statement))} The verbatim line is in the commit that opened this task; a transcript span is not stored in the corpus.</p>`;
11640
+ return `<p>Evidence, measured over the corpus: ${escapeText(flatten(evidence.detail))}</p>`;
11641
+ };
11642
+
10806
11643
  //#endregion
10807
11644
  //#region packages/sleep/dist/phases/dedup-merge.js
10808
11645
  /**
@@ -10861,6 +11698,16 @@ const confidenceDecay = (env) => Effect.gen(function* () {
10861
11698
  * One commit for the whole batch, not one per pair. A keeper's `memhtml-supersedes` points at its
10862
11699
  * dropped file's ARCHIVE path, which is where that file lives only after this commit lands.
10863
11700
  * Splitting them would create a dangling href in the commit that made it dangle.
11701
+ *
11702
+ * ## A vetoed pair becomes a review task
11703
+ *
11704
+ * Surface 1 of issue #44, second detector. The veto is the phase's strongest signal that something
11705
+ * needs a HUMAN rather than a merge: two memories a cosine says are the same and a divergence
11706
+ * predicate says cannot both be true is either a correction the corpus has not recorded as one, or a
11707
+ * pair of facts about different things that read alike. Neither resolution is a nightly job's to make —
11708
+ * choosing the winner of a contradiction is a one-way door on stored belief — and the count alone told
11709
+ * nobody. {@link mintVetoTasks} opens one task per vetoed pair NAMING THE PREDICATE that fired, in the
11710
+ * same commit as the folds.
10864
11711
  */
10865
11712
  /**
10866
11713
  * The mining floor when a model is bound. RECALL-oriented, and deliberately below the merge floor.
@@ -11002,12 +11849,41 @@ const dedupMerge = (env) => Effect.gen(function* () {
11002
11849
  * could call a model, which is what makes the existing dedup tests an oracle for the rest.
11003
11850
  */
11004
11851
  const decisions = mergeCandidates(oriented);
11005
- return yield* commitMerges(env, decisions, {
11006
- candidates: oriented.length,
11007
- components: 0,
11008
- llmGroups: 0,
11009
- vetoed: oriented.length - decisions.length
11010
- });
11852
+ return yield* commitMerges(
11853
+ env,
11854
+ decisions,
11855
+ {
11856
+ candidates: oriented.length,
11857
+ components: 0,
11858
+ llmGroups: 0,
11859
+ vetoed: oriented.length - decisions.length
11860
+ },
11861
+ /**
11862
+ * Every mined pair on this arm cleared 0.92, so a vetoed one here is a near-certain duplicate
11863
+ * the divergence predicates refused — which is exactly the pair issue #44 wants a human to
11864
+ * look at.
11865
+ *
11866
+ * **`judged: false`, so a modelless night MINTS but never SWEEPS.** No model call failed here,
11867
+ * so an earlier reading called this arm full-strength — and that reading closed human queues.
11868
+ * The two arms mine at DIFFERENT floors: this one at `NEAR_DUPLICATE_THRESHOLD` (0.92) and the
11869
+ * model arm at `DEDUP_COMPONENT_FLOOR` (0.86). A pair vetoed between 0.86 and 0.92 on a night
11870
+ * with credentials is INVISIBLE to this arm — not gone, just below the floor it can see — so its
11871
+ * `liveKeys` omits that pair's key and the sweep would archive a real review because the run
11872
+ * happened to have no credentials. That is exactly what `tasks.ts`'s
11873
+ * `closeVanishedDetections` contract forbids: "sweeping there would close a human's queue every
11874
+ * credential-free night."
11875
+ *
11876
+ * The asymmetry is stated rather than repaired, because it cannot be repaired here: mining this
11877
+ * arm at 0.86 would widen what a no-model night MERGES, and the deterministic floor is the one
11878
+ * number this phase's safety rests on. So the degraded night does the half it can do honestly —
11879
+ * open a task for every divergence it can see — and leaves closure to a night that evaluated the
11880
+ * whole candidate set.
11881
+ */
11882
+ {
11883
+ vetoed: vetoedPairs(oriented),
11884
+ judged: false
11885
+ }
11886
+ );
11011
11887
  }
11012
11888
  /**
11013
11889
  * The component graph: the mined edges at the recall floor, unioned with the frame-key exact
@@ -11131,16 +12007,159 @@ const dedupMerge = (env) => Effect.gen(function* () {
11131
12007
  const proposed = [...groupPairs, ...remaining];
11132
12008
  const decisions = mergeCandidates(proposed, { threshold: 0 });
11133
12009
  return {
11134
- ...yield* commitMerges(env, decisions, {
11135
- candidates: proposed.length,
11136
- components: components.length,
11137
- llmGroups,
11138
- vetoed: proposed.length - decisions.length,
11139
- skipped
11140
- }),
12010
+ ...yield* commitMerges(
12011
+ env,
12012
+ decisions,
12013
+ {
12014
+ candidates: proposed.length,
12015
+ components: components.length,
12016
+ llmGroups,
12017
+ vetoed: proposed.length - decisions.length,
12018
+ skipped
12019
+ },
12020
+ /**
12021
+ * On this arm a vetoed pair is one the MODEL grouped as the same memory, or one that cleared
12022
+ * 0.92 with no group claiming it, and the veto then refused it for a divergence. Both readings
12023
+ * are the issue's case: a semantic reader said "same" and a deterministic predicate said "these
12024
+ * differ in a way that matters", and the resolution — is one a correction of the other? — is a
12025
+ * human's.
12026
+ *
12027
+ * `judged` is false when a batch's call failed, because those components were never partitioned:
12028
+ * their pairs reach the veto only through the mined arm, so a night that lost a call cannot say
12029
+ * whether a pair it did not see is still a candidate.
12030
+ */
12031
+ {
12032
+ vetoed: vetoedPairs(proposed),
12033
+ judged: skipped === 0
12034
+ }
12035
+ ),
11141
12036
  llmCalls
11142
12037
  };
11143
12038
  });
12039
+ /** The detector name every near-duplicate review task is keyed and swept under. */
12040
+ const DEDUP_REVIEW_DETECTOR = "dedup-merge";
12041
+ /**
12042
+ * The proposed pairs the divergence veto refused, with WHICH predicate fired.
12043
+ *
12044
+ * The three predicates are pure, exported, and independently callable, so the phase can name the one
12045
+ * that fired instead of reporting "vetoed". That distinction is the whole value of the task: "these two
12046
+ * carry different numbers" tells a reviewer to compare the numbers, "exactly one of them is negated"
12047
+ * tells them one is probably a correction of the other, and "vetoed" tells them to read both files from
12048
+ * scratch.
12049
+ *
12050
+ * Re-running the predicates rather than threading a reason out of `mergeCandidates` keeps the domain
12051
+ * filter's signature alone: it returns the decisions it made, and asking it to also return a
12052
+ * per-refusal reason would make every caller carry a channel one caller reads. The predicates are pure
12053
+ * token-set comparisons over text already in memory, and this runs over the proposed set once.
12054
+ *
12055
+ * A pair with either text missing is NOT vetoed — the filter skips the veto for it too, since it cannot
12056
+ * evaluate one — so those are absent here, which is correct: an unevaluated pair is not a divergence
12057
+ * anyone found.
12058
+ */
12059
+ const vetoedPairs = (proposed) => proposed.flatMap((pair) => {
12060
+ const keepText = pair.keepText;
12061
+ const dropText = pair.dropText;
12062
+ if (keepText === void 0 || dropText === void 0) return [];
12063
+ const predicates = [
12064
+ ...negationDivergent(keepText, dropText) ? ["one side is negated and the other is not"] : [],
12065
+ ...numericTokenDivergent(keepText, dropText) ? ["the two carry different numbers"] : [],
12066
+ ...variantQualifierDivergent(keepText, dropText) ? ["the two name different product variants"] : []
12067
+ ];
12068
+ if (predicates.length === 0) return [];
12069
+ return [{
12070
+ keepPath: pair.keepPath,
12071
+ dropPath: pair.dropPath,
12072
+ similarity: pair.similarity,
12073
+ predicates
12074
+ }];
12075
+ });
12076
+ /**
12077
+ * Mint one review task per vetoed pair, and sweep the ones that stopped diverging.
12078
+ *
12079
+ * **The key is the two PATHS sorted.** A path is the id of a memory in this corpus, and the question is
12080
+ * about these two files — so unlike the merge itself, which orients keeper-then-drop from corpus dates,
12081
+ * the review question is unordered and sorting is what makes tomorrow's `(b, a)` key with today's
12082
+ * `(a, b)`.
12083
+ *
12084
+ * **The evidence is a MEASUREMENT.** The predicate that fired is a fact about the two token sets, and
12085
+ * no sentence in either file states it. The paths ride in the detail so a reviewer can open both.
12086
+ *
12087
+ * A pair whose veto STOPS firing — because a human corrected one of the two, or because one was
12088
+ * archived — is closed by the sweep, which is right: the divergence was the finding, and it is gone.
12089
+ *
12090
+ * **The claim NAMES THE TWO PATHS, and that is a correctness requirement rather than a nicety.** See
12091
+ * {@link vetoClaim}.
12092
+ */
12093
+ const mintVetoTasks = (env, vetoed, judged) => Effect.gen(function* () {
12094
+ const budget = budgetFor(env);
12095
+ /**
12096
+ * Keyed and de-duplicated before minting, then walked in key order, so which pairs a budget-capped
12097
+ * night surfaces is a function of the pairs rather than of the arm that proposed them.
12098
+ */
12099
+ const byKey = /* @__PURE__ */ new Map();
12100
+ for (const pair of vetoed) {
12101
+ const key = detectionKey(DEDUP_REVIEW_DETECTOR, vetoFinding(pair));
12102
+ if (!byKey.has(key)) byKey.set(key, pair);
12103
+ }
12104
+ let minted = 0;
12105
+ let refreshed = 0;
12106
+ let framed = 0;
12107
+ let dismissed = 0;
12108
+ for (const key of [...byKey.keys()].sort()) {
12109
+ const pair = byKey.get(key);
12110
+ if (pair === void 0) continue;
12111
+ const outcome = yield* mintDetectedTask(env, budget, {
12112
+ detector: DEDUP_REVIEW_DETECTOR,
12113
+ finding: vetoFinding(pair),
12114
+ title: `Review near-duplicates vetoed for divergence: ${basenameOf$1(pair.keepPath)} and ${basenameOf$1(pair.dropPath)}`,
12115
+ claim: vetoClaim(pair),
12116
+ detail: "Sleep refused to fold them because folding keeps the OLDER file, so a blind merge of a correction into the memory it corrects would restore the error the correction was written to fix.",
12117
+ evidence: {
12118
+ kind: "measurement",
12119
+ detail: vetoEvidence(pair)
12120
+ }
12121
+ });
12122
+ if (outcome === "minted") minted += 1;
12123
+ else if (outcome === "refreshed") refreshed += 1;
12124
+ else if (outcome === "framed") framed += 1;
12125
+ else if (outcome === "dismissed") dismissed += 1;
12126
+ }
12127
+ const closed = judged ? yield* closeVanishedDetections(env, DEDUP_REVIEW_DETECTOR, new Set(byKey.keys())) : 0;
12128
+ return {
12129
+ minted,
12130
+ refreshed,
12131
+ framed,
12132
+ dismissed,
12133
+ closed
12134
+ };
12135
+ });
12136
+ /**
12137
+ * The claim a vetoed pair becomes: the work stated as work, with THE TWO PATHS IN IT.
12138
+ *
12139
+ * The paths are in the claim because `mintDetectedTask`'s frame-key proximity check reads it, and a
12140
+ * CONSTANT claim caps this detector's queue at one task. The previous wording — "review:
12141
+ * near-duplicates vetoed for divergence — is one a correction of the other?" — keys (measured against
12142
+ * `frameKeyOf`) on `review: near-duplicates vetoed for divergence — is one a correction of`, a non-null
12143
+ * key EVERY vetoed pair shares. So the first pair minted, the second answered `framed`, and a night that
12144
+ * vetoed nine pairs surfaced one. The check exists to catch two DETECTORS describing one work item in
12145
+ * different words; it must never fire between two findings of one detector, which the digest already
12146
+ * separates.
12147
+ *
12148
+ * With the paths in the value position each pair's frame is its own (measured: three pairs, three
12149
+ * distinct keys), so the check still fires against a differently-worded task about the SAME two files
12150
+ * and no longer fires between different pairs. That is the same shape `entity-resolution` and
12151
+ * `edge-typing` already have — both measured `null`, because their claims put the identities in a
12152
+ * position the rule reads as the frame rather than the value.
12153
+ *
12154
+ * The paths left the `detail` when they arrived here, so a reviewer reads them once.
12155
+ */
12156
+ const vetoClaim = (pair) => `review: ${pair.keepPath} and ${pair.dropPath} are near-duplicates the divergence veto refused to fold.`;
12157
+ /** The canonical finding string: the two paths, sorted. See {@link mintVetoTasks}. */
12158
+ const vetoFinding = (pair) => pair.keepPath < pair.dropPath ? `${pair.keepPath} ${pair.dropPath}` : `${pair.dropPath} ${pair.keepPath}`;
12159
+ /** The evidence line: which predicates fired, and how near the two bodies measured. */
12160
+ const vetoEvidence = (pair) => `${pair.predicates.join("; ")} — at cosine ${pair.similarity.toFixed(3)}, at or above the ${String(DEDUP_COMPONENT_FLOOR)} candidate floor`;
12161
+ /** A path's filename without its extension, for a title that fits `ls` and a commit subject. */
12162
+ const basenameOf$1 = (path) => path.slice(path.lastIndexOf("/") + 1).replace(/\.html$/, "");
11144
12163
  /**
11145
12164
  * Archive each drop, stamp each keeper, and commit once.
11146
12165
  *
@@ -11160,16 +12179,20 @@ const dedupMerge = (env) => Effect.gen(function* () {
11160
12179
  * night two, and a counter bumped by a run that wrote nothing is a night of evidence the corpus never
11161
12180
  * saw.
11162
12181
  */
11163
- const commitMerges = (env, decisions, base) => Effect.gen(function* () {
11164
- if (decisions.length === 0) return emptyOutcome({
11165
- ...base,
11166
- merged: 0,
11167
- vanished: 0
11168
- });
12182
+ const commitMerges = (env, decisions, base, review) => Effect.gen(function* () {
12183
+ /**
12184
+ * A dry run counts the folds and the vetoes and mints nothing. Every count above is already real on
12185
+ * a dry run because an operator sizing a night needs them; a TASK is a write, so it waits for a
12186
+ * real night the same way the archives do.
12187
+ */
11169
12188
  if (env.dryRun) return emptyOutcome({
11170
12189
  ...base,
11171
12190
  merged: decisions.length,
11172
- vanished: 0
12191
+ vanished: 0,
12192
+ tasksMinted: 0,
12193
+ tasksFramed: 0,
12194
+ tasksDismissed: 0,
12195
+ tasksClosed: 0
11173
12196
  });
11174
12197
  let merged = 0;
11175
12198
  let vanished = 0;
@@ -11182,14 +12205,26 @@ const commitMerges = (env, decisions, base) => Effect.gen(function* () {
11182
12205
  yield* stampFile(env, decision.keepPath, [link("supersedes", hrefFor(archived)), meta("memhtml-updated", env.at)]);
11183
12206
  merged += 1;
11184
12207
  }
12208
+ /**
12209
+ * The vetoed pairs become tasks in the SAME commit as the folds, and the mint runs even when
12210
+ * nothing folded — which is why the old `decisions.length === 0` early return is gone. A night
12211
+ * whose every candidate was vetoed is precisely the night with the most for a human to decide, and
12212
+ * returning early on it would have made surface 1 unreachable on exactly that night.
12213
+ */
12214
+ const tasks = yield* mintVetoTasks(env, review.vetoed, review.judged);
11185
12215
  const final = {
11186
12216
  ...base,
11187
12217
  merged,
11188
- vanished
12218
+ vanished,
12219
+ tasksMinted: tasks.minted,
12220
+ tasksFramed: tasks.framed,
12221
+ tasksDismissed: tasks.dismissed,
12222
+ tasksClosed: tasks.closed
11189
12223
  };
12224
+ if (merged === 0 && tasks.minted === 0 && tasks.refreshed === 0 && tasks.closed === 0) return emptyOutcome(final);
11190
12225
  return {
11191
12226
  counts: final,
11192
- commitSha: yield* commitPhase(env, "dedup-merge", `fold ${merged} near-duplicates into canonicals`, final),
12227
+ commitSha: yield* commitPhase(env, "dedup-merge", `fold ${merged} near-duplicates into canonicals`, final, tasks.minted + tasks.closed === 0 ? void 0 : `deferred ${tasks.minted} vetoed pairs to review tasks` + (tasks.closed === 0 ? "" : `; closed ${tasks.closed}: no longer detected`)),
11193
12228
  llmCalls: 0
11194
12229
  };
11195
12230
  });
@@ -11249,6 +12284,13 @@ const commitMerges = (env, decisions, base) => Effect.gen(function* () {
11249
12284
  * contradiction is a one-way door on stored belief, and it belongs to an agent or a human, not to a
11250
12285
  * nightly job.
11251
12286
  *
12287
+ * **A single-detection contradiction becomes a TASK.** Surface 1 of issue #44, third detector, and the
12288
+ * one whose gap was widest: a contradiction at `detections = 1` is written nowhere at all, so a real
12289
+ * conflict is invisible for a night and one the model never repeats is invisible forever. The task
12290
+ * names both files and carries the model's confidence and rationale, and it CLOSES on the second night
12291
+ * when the edge is promoted — the corpus then records the conflict where a reader will find it, which
12292
+ * is a better home for it than a to-do item.
12293
+ *
11252
12294
  * This phase replaced `conflict-detection`, which asked one `generateObject` per pair for a stance
11253
12295
  * verdict over `{contradicts, entails, neutral}`. Contradiction is now one more verdict in the same
11254
12296
  * list, with the same corroboration gate. A run whose commits predate the rename carries
@@ -11414,7 +12456,11 @@ const edgeTyping = (env) => Effect.gen(function* () {
11414
12456
  promoted: 0,
11415
12457
  skipped: 0,
11416
12458
  capped: 0,
11417
- duplicates: 0
12459
+ duplicates: 0,
12460
+ tasksMinted: 0,
12461
+ tasksFramed: 0,
12462
+ tasksDismissed: 0,
12463
+ tasksClosed: 0
11418
12464
  };
11419
12465
  if (candidates.length === 0) return emptyOutcome(zero);
11420
12466
  if (env.dryRun) return emptyOutcome({
@@ -11476,6 +12522,14 @@ const edgeTyping = (env) => Effect.gen(function* () {
11476
12522
  /** Second-and-later verdicts naming a key their batch had already answered for. */
11477
12523
  let duplicates = 0;
11478
12524
  let llmCalls = 0;
12525
+ /**
12526
+ * Contradictions this night detected for the FIRST time, so below the promotion gate.
12527
+ *
12528
+ * A value rather than a count, for the same reason `entity-resolution` keeps its review pairs: a
12529
+ * conflict the gate declined to write is a conflict nobody is told about, and a human deciding
12530
+ * which of two claims survives is exactly what the gate is holding the decision open for.
12531
+ */
12532
+ const deferred = [];
11479
12533
  for (const batch of batches) {
11480
12534
  /** Opaque keys again, so a verdict cannot name a path. Each SIDE is sliced to its budget. */
11481
12535
  const keyed = keyMembers(batch, (candidate) => pairText(candidate.srcText.slice(0, EDGE_PAIR_SIDE_CHARS), candidate.dstText.slice(0, EDGE_PAIR_SIDE_CHARS)));
@@ -11529,6 +12583,22 @@ const edgeTyping = (env) => Effect.gen(function* () {
11529
12583
  dstPath: candidate.pair.dst,
11530
12584
  at: env.at
11531
12585
  }))[0];
12586
+ /**
12587
+ * A first detection is the case issue #44 asks for a task about: the model asserts these two
12588
+ * claims cannot both be true, and the corroboration gate correctly refuses to write it into
12589
+ * the files on one night's evidence — so a real contradiction sits invisible for a night,
12590
+ * and one the model will not see again sits invisible forever. Deferring it to a human is
12591
+ * the third detector on surface 1, and it is the ONLY one of the three where a second night
12592
+ * resolves the finding on its own: at `detections >= 2` the edge is promoted and the task
12593
+ * closes, because the corpus now records the conflict where a reader will find it.
12594
+ */
12595
+ if (row !== void 0 && row.detections < 2) deferred.push({
12596
+ src: candidate.pair.src,
12597
+ dst: candidate.pair.dst,
12598
+ confidence: verdict.confidence,
12599
+ detections: row.detections,
12600
+ ...verdict.rationale === void 0 ? {} : { rationale: verdict.rationale }
12601
+ });
11532
12602
  if (row === void 0 || row.detections < 2 || row.promoted === 1) continue;
11533
12603
  if (promoted + typed >= 50) {
11534
12604
  capped += 1;
@@ -11606,6 +12676,13 @@ const edgeTyping = (env) => Effect.gen(function* () {
11606
12676
  if (yield* stampFile(env, subject, [link(verdict.rel, hrefFor(object)), meta("memhtml-updated", env.at)])) typed += 1;
11607
12677
  }
11608
12678
  }
12679
+ /**
12680
+ * The single-detection contradictions become tasks in the SAME commit as the promotions. The
12681
+ * sweep is gated on a night that judged its whole candidate set: `skipped` counts pairs whose
12682
+ * batch's call failed as well as pairs whose endpoint the tree no longer holds, and a pair the
12683
+ * model was never asked about must not read as a pair the model stopped contradicting.
12684
+ */
12685
+ const tasks = yield* mintContradictionTasks(env, deferred, skipped === 0);
11609
12686
  const counts = {
11610
12687
  candidates: candidates.length,
11611
12688
  judged,
@@ -11614,19 +12691,91 @@ const edgeTyping = (env) => Effect.gen(function* () {
11614
12691
  promoted,
11615
12692
  skipped,
11616
12693
  capped,
11617
- duplicates
12694
+ duplicates,
12695
+ tasksMinted: tasks.minted,
12696
+ tasksFramed: tasks.framed,
12697
+ tasksDismissed: tasks.dismissed,
12698
+ tasksClosed: tasks.closed
11618
12699
  };
11619
- if (promoted === 0 && typed === 0) return {
12700
+ if (promoted === 0 && typed === 0 && tasks.minted === 0 && tasks.refreshed === 0 && tasks.closed === 0) return {
11620
12701
  counts,
11621
12702
  commitSha: null,
11622
12703
  llmCalls
11623
12704
  };
11624
12705
  return {
11625
12706
  counts,
11626
- commitSha: yield* commitPhase(env, "edge-typing", `promote ${typed} typed edges and ${promoted} corroborated contradictions`, counts),
12707
+ commitSha: yield* commitPhase(env, "edge-typing", `promote ${typed} typed edges and ${promoted} corroborated contradictions`, counts, tasks.minted + tasks.closed === 0 ? void 0 : `deferred ${tasks.minted} single-detection contradictions to review tasks` + (tasks.closed === 0 ? "" : `; closed ${tasks.closed}: no longer detected`)),
11627
12708
  llmCalls
11628
12709
  };
11629
12710
  });
12711
+ /** The detector name every contradiction review task is keyed and swept under. */
12712
+ const EDGE_REVIEW_DETECTOR = "edge-typing";
12713
+ /**
12714
+ * Mint one review task per held-back contradiction, and sweep the ones that stopped being held back.
12715
+ *
12716
+ * **The key is the REL plus the two paths sorted.** The rel is in it because `contradicts` is one of
12717
+ * several verdicts a pair could earn and each would be a different question; the paths are sorted
12718
+ * because a contradiction is symmetric — that symmetry is the phase's own stated reason for promoting
12719
+ * it into both files — so the question is unordered and tonight's `(b, a)` must key with last night's
12720
+ * `(a, b)`.
12721
+ *
12722
+ * **The evidence is a MEASUREMENT even though the model supplied a rationale.** The rationale is prose
12723
+ * ABOUT the two claims, not a span copied out of either, so it would fail the verbatim check on every
12724
+ * mint — and rightly, since the check exists to stop a model's sentence from being presented as a
12725
+ * citation. It rides in the measurement's own text, attributed to the model, where a reader can weigh
12726
+ * it as an opinion.
12727
+ *
12728
+ * **The sweep here is the one whose closure is a good outcome.** A pair promoted on its second night
12729
+ * leaves `deferred`, so the task closes — and the corpus now carries the `contradicts` edge in both
12730
+ * files, which is a better place for the conflict to live than a to-do item. The other two detectors
12731
+ * close when a finding evaporates; this one also closes when the system resolves it.
12732
+ */
12733
+ const mintContradictionTasks = (env, deferred, judged) => Effect.gen(function* () {
12734
+ const budget = budgetFor(env);
12735
+ const byKey = /* @__PURE__ */ new Map();
12736
+ for (const pending of deferred) {
12737
+ const key = detectionKey(EDGE_REVIEW_DETECTOR, contradictionFinding(pending));
12738
+ if (!byKey.has(key)) byKey.set(key, pending);
12739
+ }
12740
+ let minted = 0;
12741
+ let refreshed = 0;
12742
+ let framed = 0;
12743
+ let dismissed = 0;
12744
+ /** Key order, so which pairs a budget-capped night surfaces is a function of the pairs. */
12745
+ for (const key of [...byKey.keys()].sort()) {
12746
+ const pending = byKey.get(key);
12747
+ if (pending === void 0) continue;
12748
+ const outcome = yield* mintDetectedTask(env, budget, {
12749
+ detector: EDGE_REVIEW_DETECTOR,
12750
+ finding: contradictionFinding(pending),
12751
+ title: `Decide a contradiction between ${basenameOf(pending.src)} and ${basenameOf(pending.dst)}`,
12752
+ claim: `decide: ${pending.src} and ${pending.dst} make claims that cannot both be true.`,
12753
+ detail: `Detected once. The edge is written into both files only at ${String(2)} detections, so nothing in the corpus records this conflict yet. Sleep never picks the winner of a contradiction: that is a one-way door on stored belief.`,
12754
+ evidence: {
12755
+ kind: "measurement",
12756
+ detail: contradictionEvidence(pending)
12757
+ }
12758
+ });
12759
+ if (outcome === "minted") minted += 1;
12760
+ else if (outcome === "refreshed") refreshed += 1;
12761
+ else if (outcome === "framed") framed += 1;
12762
+ else if (outcome === "dismissed") dismissed += 1;
12763
+ }
12764
+ const closed = judged ? yield* closeVanishedDetections(env, EDGE_REVIEW_DETECTOR, new Set(byKey.keys())) : 0;
12765
+ return {
12766
+ minted,
12767
+ refreshed,
12768
+ framed,
12769
+ dismissed,
12770
+ closed
12771
+ };
12772
+ });
12773
+ /** The canonical finding string: the rel and the two paths, sorted. */
12774
+ const contradictionFinding = (pending) => pending.src < pending.dst ? `contradicts ${pending.src} ${pending.dst}` : `contradicts ${pending.dst} ${pending.src}`;
12775
+ /** The evidence line: the confidence, the detection count, and the model's rationale if it gave one. */
12776
+ const contradictionEvidence = (pending) => `the model judged this a contradiction at confidence ${pending.confidence.toFixed(2)}, detection ${String(pending.detections)} of ${String(2)}` + (pending.rationale === void 0 || pending.rationale.trim() === "" ? "" : `; it said: ${pending.rationale.replace(/\s+/g, " ").trim()}`);
12777
+ /** A path's filename without its extension, for a title that fits `ls` and a commit subject. */
12778
+ const basenameOf = (path) => path.slice(path.lastIndexOf("/") + 1).replace(/\.html$/, "");
11630
12779
 
11631
12780
  //#endregion
11632
12781
  //#region packages/sleep/dist/phases/entity-resolution.js
@@ -11670,6 +12819,15 @@ const edgeTyping = (env) => Effect.gen(function* () {
11670
12819
  * not cluster, and a cluster below {@link ENTITY_CONFIDENCE_FLOOR}, both land in `reviewCandidates`. An
11671
12820
  * entity merge is a one-way door on stored identity: no later commit separates two subjects whose
11672
12821
  * memories were fused, and the failure mode of an over-eager gate is silent and permanent.
12822
+ *
12823
+ * **And every one of them now also becomes a TASK.** `reviewCandidates: 2` in a report is issue #44's
12824
+ * motivating example of the failure this phase had: a decision the night deliberately deferred to a
12825
+ * human, reported as a number and then never seen again. A deferred decision IS a task, so
12826
+ * {@link mintReviewTasks} opens one per pair with the band, the score, and each name's file count as
12827
+ * its evidence, keyed so tomorrow refreshes rather than duplicates, and closed when the pair stops
12828
+ * being a candidate — because the pair merging, or the names disappearing, means the question is
12829
+ * answered. The counter survives beside it: the count says how many pairs the night deferred and the
12830
+ * tasks are the ones a human can act on.
11673
12831
  */
11674
12832
  /** At or above this ratio two names are the same entity. Auto-merged with no model call. */
11675
12833
  const AUTO_MERGE_THRESHOLD = .85;
@@ -12045,6 +13203,18 @@ const entityResolution = (env) => Effect.gen(function* () {
12045
13203
  let pendingCorroboration = 0;
12046
13204
  let reviewCandidates = 0;
12047
13205
  let llmCalls = 0;
13206
+ /** Model calls that came back malformed. The sweep's precondition reads this; see below. */
13207
+ let callsFailed = 0;
13208
+ /**
13209
+ * Every pair this night deferred to a human, as a value rather than only a count.
13210
+ *
13211
+ * This is issue #44's motivating case in one variable. The phase used to report
13212
+ * `reviewCandidates: 2` and the number was never seen again: a decision the night deliberately
13213
+ * declined to make evaporated, and the human it was deferred TO was never told. Keeping the pairs
13214
+ * lets the phase mint one task per pair after the loop, with the evidence that made it a
13215
+ * candidate.
13216
+ */
13217
+ const deferred = [];
12048
13218
  /**
12049
13219
  * The model core is skipped entirely on a dry run and when no model is bound, and both leave the
12050
13220
  * deterministic passes running. A dry run must make no model call and bump no counter, because a
@@ -12136,7 +13306,17 @@ const entityResolution = (env) => Effect.gen(function* () {
12136
13306
  effort: "medium",
12137
13307
  toolDescription: "Emit one cluster per subject, naming the members that are the same subject."
12138
13308
  });
12139
- if (clustering === void 0) continue;
13309
+ if (clustering === void 0) {
13310
+ /**
13311
+ * Counted, because the detected-task sweep's precondition reads it. A shard whose call
13312
+ * came back malformed left every one of its names unclustered, so the band pairs among
13313
+ * them are counted as review candidates by the pass below — which is correct for the
13314
+ * REPORT and would be wrong as the sweep's input, since the phase did not actually judge
13315
+ * them. See the sweep's own comment.
13316
+ */
13317
+ callsFailed += 1;
13318
+ continue;
13319
+ }
12140
13320
  for (const cluster of clustering.clusters) {
12141
13321
  /**
12142
13322
  * A key the batch never offered resolves to nothing, so an invented member cannot become a
@@ -12171,6 +13351,15 @@ const entityResolution = (env) => Effect.gen(function* () {
12171
13351
  }
12172
13352
  if (cluster.confidence < .7) {
12173
13353
  reviewCandidates += 1;
13354
+ deferred.push({
13355
+ entityType,
13356
+ left: merge.alias,
13357
+ right: merge.canonical,
13358
+ reason: "below-floor",
13359
+ score: cluster.confidence,
13360
+ leftFiles: counts.get(merge.alias) ?? 0,
13361
+ rightFiles: counts.get(merge.canonical) ?? 0
13362
+ });
12174
13363
  continue;
12175
13364
  }
12176
13365
  const row = (yield* bumpEntityCorroboration(env.deps.db, {
@@ -12200,7 +13389,17 @@ const entityResolution = (env) => Effect.gen(function* () {
12200
13389
  * the decision is recorded either as a merge or as a below-floor review candidate already counted
12201
13390
  * above. Counting it here as well would report one pair twice.
12202
13391
  */
12203
- reviewCandidates += character.review.filter(([left, right]) => !clusteredPairs.has(pairKey(left, right))).length;
13392
+ const bandPairs = character.review.filter(([left, right]) => !clusteredPairs.has(pairKey(left, right)));
13393
+ reviewCandidates += bandPairs.length;
13394
+ for (const [left, right] of bandPairs) deferred.push({
13395
+ entityType,
13396
+ left,
13397
+ right,
13398
+ reason: "character-band",
13399
+ score: nameSimilarity(left, right),
13400
+ leftFiles: counts.get(left) ?? 0,
13401
+ rightFiles: counts.get(right) ?? 0
13402
+ });
12204
13403
  /** One union-find over every accepted pair, so the three sources cannot disagree on a root. */
12205
13404
  const aliasToCanonical = unionPairs(counts, accepted);
12206
13405
  for (const entity of bucket) {
@@ -12220,12 +13419,32 @@ const entityResolution = (env) => Effect.gen(function* () {
12220
13419
  aliasMerges,
12221
13420
  pendingCorroboration,
12222
13421
  reviewCandidates,
13422
+ tasksMinted: 0,
13423
+ tasksFramed: 0,
13424
+ tasksDismissed: 0,
13425
+ tasksClosed: 0,
12223
13426
  filesRewritten: rewrites.size
12224
13427
  };
12225
- if (rewrites.size === 0 || env.dryRun) return {
13428
+ /**
13429
+ * A dry run stops here and mints nothing, matching what the rest of this phase already declines
13430
+ * to do on one. `reviewCandidates` is real on a dry run; the tasks it would open are not.
13431
+ */
13432
+ if (env.dryRun) return {
12226
13433
  ...emptyOutcome(counts),
12227
13434
  llmCalls
12228
13435
  };
13436
+ /**
13437
+ * The deferred decisions become task files, keyed and capped, in the SAME commit as the merges.
13438
+ *
13439
+ * One commit rather than two, because the two halves are one night's answer to the same question:
13440
+ * these pairs merged, those the phase declined to merge and handed to you. A reviewer reads the
13441
+ * pair together, and `commitPhase` commits whatever is staged, so the mints ride along.
13442
+ *
13443
+ * Mints happen even when nothing was rewritten, and that reordering is the whole point of surface
13444
+ * 1. The old early return on `rewrites.size === 0` would have skipped exactly the night this
13445
+ * feature exists for: a night whose only outcome was deferrals is a night with no rewrites.
13446
+ */
13447
+ const tasks = yield* mintReviewTasks(env, deferred, model !== void 0 && callsFailed === 0);
12229
13448
  let rewritten = 0;
12230
13449
  for (const [path, pairs] of [...rewrites.entries()].sort(([left], [right]) => left < right ? -1 : 1)) {
12231
13450
  const html = yield* readFileBytes(env, path);
@@ -12240,14 +13459,113 @@ const entityResolution = (env) => Effect.gen(function* () {
12240
13459
  }
12241
13460
  const final = {
12242
13461
  ...counts,
13462
+ tasksMinted: tasks.minted,
13463
+ tasksFramed: tasks.framed,
13464
+ tasksDismissed: tasks.dismissed,
13465
+ tasksClosed: tasks.closed,
12243
13466
  filesRewritten: rewritten
12244
13467
  };
13468
+ /**
13469
+ * Nothing staged, no commit. `commitPhase` already no-ops on an empty index, so this only spares
13470
+ * git the call — and it now has to consider the MINTS as well as the rewrites, because a night
13471
+ * whose only output is deferred-decision tasks must still commit them.
13472
+ */
13473
+ if (rewritten === 0 && tasks.minted === 0 && tasks.refreshed === 0 && tasks.closed === 0) return {
13474
+ ...emptyOutcome(final),
13475
+ llmCalls
13476
+ };
12245
13477
  return {
12246
13478
  counts: final,
12247
- commitSha: yield* commitPhase(env, "entity-resolution", `normalize ${normalized} entity names, merge ${fuzzyMerges} aliases`, final),
13479
+ commitSha: yield* commitPhase(env, "entity-resolution", `normalize ${normalized} entity names, merge ${fuzzyMerges} aliases`, final, tasks.minted + tasks.closed === 0 ? void 0 : `deferred ${tasks.minted} alias decisions to review tasks` + (tasks.closed === 0 ? "" : `; closed ${tasks.closed}: no longer detected`)),
12248
13480
  llmCalls
12249
13481
  };
12250
13482
  });
13483
+ /** The detector name every alias review task is keyed and swept under. */
13484
+ const ENTITY_REVIEW_DETECTOR = "entity-resolution";
13485
+ /**
13486
+ * Mint one review task per deferred pair, and sweep the ones that stopped being deferred.
13487
+ *
13488
+ * **The key is the entity TYPE plus the two names sorted**, and not the reason. A pair the character
13489
+ * band deferred last night and the model deferred below the floor tonight is ONE question a human has
13490
+ * to answer once — are these the same subject — so it must key the same however the night arrived at
13491
+ * it. Sorting is what makes `(laith, laith al-saadoon)` and the reverse one key; the pair is
13492
+ * unordered, because neither name is the subject of the question.
13493
+ *
13494
+ * **The evidence is a MEASUREMENT and says so.** There is no sentence anywhere in the corpus stating
13495
+ * that two names scored 0.79 against each other, so a quote would have to be manufactured. The
13496
+ * `DetectionEvidence` union makes that difference explicit rather than leaving it to a convention this
13497
+ * function could quietly break.
13498
+ *
13499
+ * **The sweep is gated on a night that had a MODEL and lost no call**, which `judged` carries.
13500
+ * `deferred` holds what the phase actually decided to defer, and a shard whose model call failed left
13501
+ * its names unclustered — so its band pairs are reported as review candidates without having been
13502
+ * judged. They ARE still live, so they belong in `liveKeys`; but a night that lost a call cannot
13503
+ * distinguish "the model decided this pair is fine" from "the model was never asked", and closing on
13504
+ * that reading would take a real review out of a human's queue because Bedrock throttled.
13505
+ *
13506
+ * **`callsFailed === 0` alone was the bug, because it is VACUOUSLY TRUE with no model bound.** The
13507
+ * caller now requires `model !== undefined` as well. A credential-free night runs only the two
13508
+ * deterministic passes, so it produces `character-band` deferrals and CANNOT produce a `below-floor`
13509
+ * one — a below-floor deferral is by definition a merge the model proposed under
13510
+ * `ENTITY_CONFIDENCE_FLOOR`, and there was no model to propose it. Its `deferred` therefore omits every
13511
+ * below-floor pair a model night opened, and sweeping against that closed those tasks on the first
13512
+ * night without credentials. `tasks.ts`'s `closeVanishedDetections` states this precondition as
13513
+ * "a phase that degraded — no model bound, a batch whose call failed — did not evaluate the candidate
13514
+ * set", and no-model is the arm that check had missed.
13515
+ */
13516
+ const mintReviewTasks = (env, deferred, judged) => Effect.gen(function* () {
13517
+ const budget = budgetFor(env);
13518
+ /**
13519
+ * Sorted and de-duplicated by key before minting, so the order tasks are opened in is a function
13520
+ * of the pairs and not of which entity type happened to be walked first — which matters once the
13521
+ * budget bites, because then the ORDER decides which pairs a human sees.
13522
+ */
13523
+ const byKey = /* @__PURE__ */ new Map();
13524
+ for (const candidate of [...deferred].sort(compareCandidates)) {
13525
+ const key = detectionKey(ENTITY_REVIEW_DETECTOR, findingFor(candidate));
13526
+ if (!byKey.has(key)) byKey.set(key, candidate);
13527
+ }
13528
+ let minted = 0;
13529
+ let refreshed = 0;
13530
+ let framed = 0;
13531
+ let dismissed = 0;
13532
+ for (const candidate of byKey.values()) {
13533
+ const outcome = yield* mintDetectedTask(env, budget, {
13534
+ detector: ENTITY_REVIEW_DETECTOR,
13535
+ finding: findingFor(candidate),
13536
+ title: `Confirm whether ${candidate.left} and ${candidate.right} are one ${candidate.entityType}`,
13537
+ claim: `confirm: are "${candidate.left}" and "${candidate.right}" the same ${candidate.entityType}?`,
13538
+ detail: "Sleep declined to merge them and deferred the decision. Merging two entities is a one-way door: no later commit separates two subjects whose memories were fused.",
13539
+ evidence: {
13540
+ kind: "measurement",
13541
+ detail: evidenceFor(candidate)
13542
+ }
13543
+ });
13544
+ if (outcome === "minted") minted += 1;
13545
+ else if (outcome === "refreshed") refreshed += 1;
13546
+ else if (outcome === "framed") framed += 1;
13547
+ else if (outcome === "dismissed") dismissed += 1;
13548
+ }
13549
+ const closed = judged ? yield* closeVanishedDetections(env, ENTITY_REVIEW_DETECTOR, new Set(byKey.keys())) : 0;
13550
+ return {
13551
+ minted,
13552
+ refreshed,
13553
+ framed,
13554
+ dismissed,
13555
+ closed
13556
+ };
13557
+ });
13558
+ /** The canonical finding string: the type and the two names, sorted. See {@link mintReviewTasks}. */
13559
+ const findingFor = (candidate) => candidate.left < candidate.right ? `${candidate.entityType} ${candidate.left} ${candidate.right}` : `${candidate.entityType} ${candidate.right} ${candidate.left}`;
13560
+ /** The evidence line: which band deferred it, at what number, and how much corpus is behind each name. */
13561
+ const evidenceFor = (candidate) => (candidate.reason === "character-band" ? `character overlap ${candidate.score.toFixed(2)}, inside the ${String(REVIEW_THRESHOLD)}-${String(AUTO_MERGE_THRESHOLD)} review band` : `the model proposed the merge at confidence ${candidate.score.toFixed(2)}, below the ${String(ENTITY_CONFIDENCE_FLOOR)} floor`) + `; "${candidate.left}" is claimed by ${String(candidate.leftFiles)} active memories and "${candidate.right}" by ${String(candidate.rightFiles)}`;
13562
+ /** Type, then the two names, then the reason. A total order, so the mint sequence is reproducible. */
13563
+ const compareCandidates = (left, right) => {
13564
+ const leftFinding = findingFor(left);
13565
+ const rightFinding = findingFor(right);
13566
+ if (leftFinding !== rightFinding) return leftFinding < rightFinding ? -1 : 1;
13567
+ return left.reason < right.reason ? -1 : left.reason > right.reason ? 1 : 0;
13568
+ };
12251
13569
 
12252
13570
  //#endregion
12253
13571
  //#region packages/sleep/dist/publish.js
@@ -12988,6 +14306,275 @@ const parseSidecar = (contents) => {
12988
14306
  };
12989
14307
  const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
12990
14308
 
14309
+ //#endregion
14310
+ //#region packages/sleep/dist/phases/task-detection.js
14311
+ /**
14312
+ * Phase 13, task detection. A batched scan over the recent active corpus for work the text records
14313
+ * and nobody opened. ONE commit for the night's mints.
14314
+ *
14315
+ * Surface 3 of issue #44, and the only one that is net-new model spend. Surfaces 1 and 2 ride on
14316
+ * decisions other phases were already making — a review band entity resolution declined, a pair the
14317
+ * divergence veto refused, a commitment the consolidator's existing call can also report — so they
14318
+ * cost tokens the night was already spending. This one asks a question nobody was asking, which is why
14319
+ * it is capped, floored, and last of the four: the issue explicitly sizes it as the surface that could
14320
+ * ship last or never.
14321
+ *
14322
+ * Four stages, and the separation is what keeps a model's sentence from becoming an assertion:
14323
+ *
14324
+ * 1. **Scan (SQL, no model).** {@link TASK_SCAN_LIMIT} most-recently-updated active non-task
14325
+ * memories, newest first, ties by path. Deterministic, so the batches and the `m1`..`mN` keys are a
14326
+ * function of the corpus.
14327
+ * 2. **Batch (deterministic).** Sliced at {@link TASK_DETECT_BATCH_SIZE} on the shared kernel, each
14328
+ * member cut to {@link TASK_DETECT_MEMBER_CHARS}. One call per batch, never one per memory: 200
14329
+ * candidates is 10 calls, and per-memory judging is 200.
14330
+ * 3. **Ask (one isolated call per batch).** A failure skips its batch and is counted. A night that
14331
+ * scanned nine batches and lost the tenth has done nine batches of work.
14332
+ * 4. **Mint (deterministic, and this is where the guards are).** The key must resolve to an offered
14333
+ * member; the confidence must clear {@link TASK_DETECT_FLOOR}; the sentence must exist VERBATIM in
14334
+ * the cited file's own article text, which `mintDetectedTask` checks by reading the file; the
14335
+ * nightly budget must have room. Everything the file says is derived from the member the model was
14336
+ * shown plus its own quoted sentence — the model never names a path, a title, or a status.
14337
+ *
14338
+ * **No self-referential loops, and ONE guard rather than two.** `recentActiveMemories` excludes `task`
14339
+ * in SQL, and that is the whole mechanism: a task is not evidence of another task, and a detector that
14340
+ * scanned its own output would restate its own queue every night. A path-prefix check on top of it was
14341
+ * written and then removed, because it could not be made to fire — the index is refreshed once in
14342
+ * preflight, so a task minted earlier in the same night is ABSENT from the projection rather than
14343
+ * present with the wrong type, and either way the statement does not return it. Mutation-verified:
14344
+ * deleting the SQL filter fails `tests/task-detection.test.ts`, and deleting the path filter did not.
14345
+ *
14346
+ * **Self-cleaning, and only from a full-strength scan.** {@link closeVanishedDetections} runs when the
14347
+ * night reached every batch — no skips — because `liveKeys` then genuinely describes the findings that
14348
+ * still exist. On a night that lost a batch it describes what the phase managed to look at, and
14349
+ * sweeping against that would close a human's review because a call was throttled.
14350
+ *
14351
+ * **Degrades and never fails.** No model bound, no candidate, a dry run, or a night where nothing
14352
+ * clears the floor all produce `ok` with counts. A credential-free run is not a broken run.
14353
+ */
14354
+ /**
14355
+ * Memories scanned per night.
14356
+ *
14357
+ * 200, matching `EDGE_TYPING_CANDIDATE_LIMIT`'s posture rather than `COMPRESS_CANDIDATE_LIMIT`'s: this
14358
+ * is a bound on what the phase READS INTO PROMPTS, and every candidate costs tokens whether or not it
14359
+ * yields a finding. At {@link TASK_DETECT_BATCH_SIZE} that is ten calls a night, which sits inside the
14360
+ * envelope issue #43 measured for the whole batching direction. Newest-first ordering is what makes
14361
+ * 200 a moving window rather than a truncation: a corpus of 2,907 is scanned in the region where
14362
+ * unresolved work actually lives, and last month's settled memories are not re-read every night.
14363
+ */
14364
+ const TASK_SCAN_LIMIT = 200;
14365
+ /**
14366
+ * Memories offered per model call.
14367
+ *
14368
+ * Twenty. The question is per member and the answer is a short list, so the batch can be wider than
14369
+ * compress's 8 (which has to hold every member's facts in the answer's generative attention) and
14370
+ * narrower than dedup's 40 (whose members are pre-grouped, so most of a batch needs no independent
14371
+ * judgment). Twenty memories at {@link TASK_DETECT_MEMBER_CHARS} is 24k characters of member text, and
14372
+ * the model has to read each one for a distinct verbatim sentence.
14373
+ */
14374
+ const TASK_DETECT_BATCH_SIZE = 20;
14375
+ /** Characters of each member shown. The house per-member budget, the same 1200 four phases use. */
14376
+ const TASK_DETECT_MEMBER_CHARS = 1200;
14377
+ /**
14378
+ * The confidence a finding must clear before a task is minted.
14379
+ *
14380
+ * 0.7, the same floor `EDGE_CONFIDENCE_FLOOR` and `ENTITY_CONFIDENCE_FLOOR` set, and for the
14381
+ * comparable reason: a false positive costs a reviewer's attention, which is the resource this whole
14382
+ * surface spends, and the failure mode of a low floor is a queue nobody reads. One number rather than
14383
+ * one per kind, because a second would be a knob nobody could state the meaning of.
14384
+ */
14385
+ const TASK_DETECT_FLOOR = .7;
14386
+ /** The detector's name: the key's namespace, the task's second tag, and the sweep's scope. */
14387
+ const TASK_DETECT_DETECTOR = "task-detection";
14388
+ /** The text a member is offered under: its title, claim, and body, the join compress and dedup use. */
14389
+ const memberText = (row) => `${row.title}\n${row.gist}\n${row.body_text}`;
14390
+ /**
14391
+ * The claim a finding becomes, by kind.
14392
+ *
14393
+ * Derived here and never asked of the model, the same decision every other phase makes about a value
14394
+ * that reaches a file. A model-written claim would be the `<mark>` span, `files.gist`, and the frame
14395
+ * key the proximity check reads — so the one sentence that decides how this task is de-duplicated
14396
+ * against the rest of the queue would be prose a model chose.
14397
+ *
14398
+ * The verbs are the imperative a reviewer acts on: a commitment is confirmed or closed, a follow-up is
14399
+ * resolved or dismissed. Both name the SOURCE, because a task whose subject a reader has to go
14400
+ * looking for is a task they skip.
14401
+ */
14402
+ const claimFor = (kind, path) => kind === "commitment" ? `confirm: ${path} records a commitment with nothing saying it was done.` : `resolve: ${path} leaves a follow-up open.`;
14403
+ /** The title a finding becomes. Same two shapes, without the trailing sentence punctuation. */
14404
+ const titleFor$1 = (kind, row) => kind === "commitment" ? `Confirm the commitment recorded in ${row.title}` : `Resolve the follow-up left open by ${row.title}`;
14405
+ const taskDetection = (env) => Effect.gen(function* () {
14406
+ const model = env.deps.model;
14407
+ if (model === void 0) return {
14408
+ ...emptyOutcome(ZERO),
14409
+ detail: "no model bound"
14410
+ };
14411
+ /**
14412
+ * The candidate slice, with the self-scan exclusion inside the statement. See the phase header:
14413
+ * `recentActiveMemories` filters `memory_type` in SQL, and no second path-level filter is added
14414
+ * here on purpose — a detected task's row either carries `memory_type = 'task'` and the statement
14415
+ * excludes it, or is absent from the index entirely and the statement never sees it. A path-prefix
14416
+ * check would be a guard with no reachable input, which is worse than no guard: it reads as the
14417
+ * thing standing between a task and the prompt while the statement is what actually does it.
14418
+ */
14419
+ const candidates = yield* recentActiveMemories(env.deps.db, { limit: 200 });
14420
+ if (candidates.length === 0) return emptyOutcome(ZERO);
14421
+ /**
14422
+ * A dry run stops after the deterministic half, before the calls. The candidate count is the
14423
+ * number an operator sizing a night wants, and a preview that spent the tokens to then discard
14424
+ * every answer would be the most expensive way to produce it. `entity-resolution` makes the same
14425
+ * choice for a stronger reason (its dry run would have to manufacture a night of corroboration);
14426
+ * here it is simply that nothing the calls buy survives a dry run.
14427
+ */
14428
+ if (env.dryRun) return emptyOutcome({
14429
+ ...ZERO,
14430
+ candidates: candidates.length
14431
+ });
14432
+ const batches = assembleBatches([candidates], { maxMembers: 20 });
14433
+ const modelKey = modelFor(env.deps, "task-detection");
14434
+ const budget = budgetFor(env);
14435
+ let llmCalls = 0;
14436
+ let findings = 0;
14437
+ let minted = 0;
14438
+ let refreshed = 0;
14439
+ let unverified = 0;
14440
+ let framed = 0;
14441
+ let dismissed = 0;
14442
+ let skipped = 0;
14443
+ /**
14444
+ * Every key this night's scan SAW, whether or not it minted and whether or not it cleared the floor.
14445
+ * The sweep's input; see the `liveKeys.add` below for why the floor is not a filter here.
14446
+ */
14447
+ const liveKeys = /* @__PURE__ */ new Set();
14448
+ for (const batch of batches) {
14449
+ const keyed = keyMembers(batch, memberText, { charBudget: TASK_DETECT_MEMBER_CHARS });
14450
+ llmCalls += 1;
14451
+ const answer = yield* batchCall(model, `task-detection batch of ${batch.length}`, {
14452
+ schema: TaskDetection,
14453
+ system: TASK_DETECT_SYSTEM,
14454
+ prompt: taskDetectPrompt(keyed.keyed),
14455
+ modelKey,
14456
+ effort: "medium",
14457
+ toolDescription: "Emit one finding per memory that records open work, quoting the sentence verbatim."
14458
+ });
14459
+ if (answer === void 0) {
14460
+ skipped += 1;
14461
+ continue;
14462
+ }
14463
+ /**
14464
+ * The keys this batch has already yielded a finding for, so a SECOND finding naming one is
14465
+ * dropped. Same guard `edge-typing` carries and the same reason: nothing in the schema stops a
14466
+ * model from emitting two findings for one member, and acting on both would mint two tasks about
14467
+ * one memory whose only difference is which sentence was quoted. `resolveKeys` does not help,
14468
+ * because it is called one key at a time here — a finding names one member.
14469
+ */
14470
+ const answered = /* @__PURE__ */ new Set();
14471
+ for (const finding of answer.findings) {
14472
+ const [row] = resolveKeys(keyed, [finding.memberKey]);
14473
+ if (row === void 0) continue;
14474
+ if (answered.has(finding.memberKey)) continue;
14475
+ answered.add(finding.memberKey);
14476
+ findings += 1;
14477
+ /**
14478
+ * The key is the SOURCE PATH plus the normalized sentence, so the same commitment found again
14479
+ * tomorrow keys the same and refreshes. The path is in the key rather than only the sentence
14480
+ * because one sentence can legitimately appear in two memories — a corrected memory and its
14481
+ * correction share most of their prose — and those are two findings a reviewer decides
14482
+ * separately. `detectionKey` normalizes, so a member whose whitespace the chunker changed keys
14483
+ * the same.
14484
+ */
14485
+ const key = detectionKey(TASK_DETECT_DETECTOR, `${row.path} ${finding.sentence}`);
14486
+ /**
14487
+ * The key is live BEFORE the floor gate, which is `closeVanishedDetections`' contract verbatim:
14488
+ * `liveKeys` is every finding the detector SAW, not every finding it minted.
14489
+ *
14490
+ * A below-floor finding was SEEN. The sentence is still in the file and the model still reported
14491
+ * it; only the confidence moved. Adding the key after the gate makes a task's life a function of
14492
+ * confidence JITTER across nights — minted at 0.72, swept at 0.68, re-minted at 0.71 — and the
14493
+ * sweep archives, so each cycle takes the file out of the human's directory and back into it
14494
+ * with a fresh `memhtml-created`. The finding VANISHING is what closure is for, and a confidence
14495
+ * that dipped one hundredth is not that.
14496
+ *
14497
+ * A below-floor finding therefore keeps its task open without ever being able to open one, which
14498
+ * is the asymmetry the floor is supposed to buy: the floor guards what enters a human's queue,
14499
+ * not what stays there once they have been shown it.
14500
+ */
14501
+ liveKeys.add(key);
14502
+ if (finding.confidence < .7) continue;
14503
+ const outcome = yield* mintDetectedTask(env, budget, {
14504
+ detector: TASK_DETECT_DETECTOR,
14505
+ finding: `${row.path} ${finding.sentence}`,
14506
+ title: titleFor$1(finding.kind, row),
14507
+ claim: claimFor(finding.kind, row.path),
14508
+ detail: `Detected as ${finding.kind === "commitment" ? "a commitment" : "an unresolved follow-up"} at confidence ${finding.confidence.toFixed(2)} in a ${row.memory_type} memory last updated ${row.updated_at}.`,
14509
+ evidence: {
14510
+ kind: "quote",
14511
+ quote: finding.sentence,
14512
+ sourcePath: row.path
14513
+ }
14514
+ });
14515
+ if (outcome === "minted") minted += 1;
14516
+ else if (outcome === "refreshed") refreshed += 1;
14517
+ else if (outcome === "unverified") unverified += 1;
14518
+ else if (outcome === "framed") framed += 1;
14519
+ else if (outcome === "dismissed") dismissed += 1;
14520
+ }
14521
+ }
14522
+ /**
14523
+ * The sweep, only from a full-strength scan. `skipped > 0` means at least one batch's memories
14524
+ * went unread, so a finding of theirs is missing from `liveKeys` because the phase could not look
14525
+ * rather than because it is gone.
14526
+ */
14527
+ const closed = skipped === 0 ? yield* closeVanishedDetections(env, TASK_DETECT_DETECTOR, liveKeys) : 0;
14528
+ const counts = {
14529
+ candidates: candidates.length,
14530
+ batches: batches.length,
14531
+ findings,
14532
+ minted,
14533
+ refreshed,
14534
+ unverified,
14535
+ framed,
14536
+ dismissed,
14537
+ closed,
14538
+ capped: budget.overflow,
14539
+ skipped
14540
+ };
14541
+ /**
14542
+ * A refresh writes a `memhtml-updated` stamp, which is a staged file, so it commits — the queue's
14543
+ * "last seen" is a fact worth a diff. Nothing staged at all leaves `commitSha: null`, which
14544
+ * `commitPhase` already produces on an empty index; the early return only spares git the call.
14545
+ */
14546
+ if (minted === 0 && refreshed === 0 && closed === 0) return {
14547
+ counts,
14548
+ commitSha: null,
14549
+ llmCalls
14550
+ };
14551
+ return {
14552
+ counts,
14553
+ commitSha: yield* commitPhase(env, "task-detection", `open ${minted} detected tasks, close ${closed} no longer detected`, counts, closed === 0 ? void 0 : "closing reason: no longer detected"),
14554
+ llmCalls
14555
+ };
14556
+ });
14557
+ /**
14558
+ * The full count SHAPE, at zero.
14559
+ *
14560
+ * Every key the phase can report is present on every path, because a report reader comparing two
14561
+ * nights reads a missing key as a phase that does not have that concept rather than as a night that
14562
+ * did none of it. Same rule `edge-typing`'s `zero` states.
14563
+ */
14564
+ const ZERO = {
14565
+ candidates: 0,
14566
+ batches: 0,
14567
+ findings: 0,
14568
+ minted: 0,
14569
+ refreshed: 0,
14570
+ unverified: 0,
14571
+ framed: 0,
14572
+ dismissed: 0,
14573
+ closed: 0,
14574
+ capped: 0,
14575
+ skipped: 0
14576
+ };
14577
+
12991
14578
  //#endregion
12992
14579
  //#region packages/sleep/dist/phases/trace-consolidation.js
12993
14580
  /**
@@ -13029,6 +14616,48 @@ const numberOr = (value, fallback) => typeof value === "number" && Number.isFini
13029
14616
  * (missing credentials, an unreachable agent, an off-contract answer), and a candidate this phase
13030
14617
  * refuses all produce `ok` with counts and a reason. INV-3 in full: a night with no Bedrock
13031
14618
  * credentials is not a broken night, and a run that lost this phase stays green.
14619
+ *
14620
+ * ## Surface 2: the same answer also carries COMMITMENTS
14621
+ *
14622
+ * The consolidator's turn now reports two lists, and the second is issue #44's surface 2. The marginal
14623
+ * cost is tokens in a call this phase was already making — no new model call, which is what makes this
14624
+ * surface cheap enough to run every night and is the reason the issue sizes it above the net-new scan.
14625
+ *
14626
+ * A commitment is not a candidate memory and does not travel through the candidate loop.
14627
+ * {@link CONSOLIDATION_KINDS} excludes `task` deliberately ("task is work to do, not something observed
14628
+ * to have happened"), and that exclusion still holds: the model reports what a transcript SAYS, and the
14629
+ * decision to open a task is made HERE, deterministically, by {@link commitmentRefusalFor} plus
14630
+ * {@link COMMITMENT_FLOOR}.
14631
+ *
14632
+ * Two arms, from one list:
14633
+ *
14634
+ * - **Unresolved** commitments mint detected tasks, sharing the night's `DETECTED_TASK_CAP` budget with
14635
+ * every other detector, keyed on a normalized digest of the STATEMENT so the same promise restated on
14636
+ * a later night refreshes rather than duplicating.
14637
+ * - **Resolved** commitments — a session showing the work done — close an OPEN detected task whose key
14638
+ * matches. That is the issue's "closure is also detected", and it is the reason a commitment that
14639
+ * arrives already-done is still worth reporting: a night that opens a task and a later night that
14640
+ * closes it are two readings of the same commitment.
14641
+ *
14642
+ * The key carries the statement and NOT the session, which is the one place surface 2 departs from
14643
+ * `task-detection`'s keying, and it is forced by what closure has to reach across. See
14644
+ * {@link commitmentKey}.
14645
+ *
14646
+ * **Only a DETECTED task is ever closed, and the guard is `closeDetectedTask`'s, on the path.** A
14647
+ * human-opened task must not be archived because a model read "shipped it" in somebody's scrollback.
14648
+ *
14649
+ * **A commitment's evidence quote never enters the corpus, exactly like a candidate's.** The task body
14650
+ * carries the model's own restatement plus the session id as a `memhtml-session` stamp; the verbatim
14651
+ * line goes in the commit message. `packages/sleep/src/tasks.ts`' `DetectionEvidence` `session` arm is
14652
+ * where that split is enforced, and its header records why the quote is not re-verified against
14653
+ * transcript bytes.
14654
+ *
14655
+ * **One commit for the batch of commitment tasks**, not one per task, and that is the one place this
14656
+ * phase departs from its one-commit-per-candidate discipline. The reason the discipline exists is that
14657
+ * a distilled memory is a standalone ASSERTION about the world a reviewer weighs on its own. A detected
14658
+ * task asserts nothing — it is a proposal, and the reviewer's decision is made in the task file rather
14659
+ * than at the commit. What the commit has to do is be reviewable, and "the night found four
14660
+ * commitments, here they are with their quotes" is one reviewable decision about one model answer.
13032
14661
  */
13033
14662
  /**
13034
14663
  * The smallest transcript worth a model's attention, in bytes.
@@ -13076,6 +14705,270 @@ const COMMIT_QUOTE_CHARS = 200;
13076
14705
  /** Where a consolidated memory lands: by kind and tag, exactly as an agent's own write is placed. */
13077
14706
  const CONSOLIDATION_TAG = "trace-consolidation";
13078
14707
  /**
14708
+ * The confidence a commitment must clear before it mints a task or closes one.
14709
+ *
14710
+ * 0.7, the same floor `TASK_DETECT_FLOOR`, `EDGE_CONFIDENCE_FLOOR`, and `ENTITY_CONFIDENCE_FLOOR` set,
14711
+ * and one number rather than one per arm. The mint arm and the closure arm read it identically on
14712
+ * purpose: they are the same judgement about the same sentence, made once, and a lower floor on closure
14713
+ * would mean a commitment too weak to open a task was strong enough to close one.
14714
+ *
14715
+ * The resource this bounds is a reviewer's attention, which is a property of the human rather than of
14716
+ * how the finding was reached — the reasoning `DETECTED_TASK_CAP` records for being shared.
14717
+ */
14718
+ const COMMITMENT_FLOOR = .7;
14719
+ /** The detector name every commitment task is keyed, tagged, and closed under. */
14720
+ const COMMITMENT_DETECTOR = "trace-commitment";
14721
+ /** The actors whose commitments are FIRST-PERSON, and therefore the only ones minted. */
14722
+ const FIRST_PERSON_ACTORS = /* @__PURE__ */ new Set(["user", "agent"]);
14723
+ /**
14724
+ * A commitment this phase will act on, or the reason it was refused.
14725
+ *
14726
+ * Deterministic and between the model and the tree, the same position {@link refusalFor} occupies for a
14727
+ * candidate memory, and every clause is a real failure mode rather than a restatement of the schema:
14728
+ *
14729
+ * - **An actor outside `user`/`agent`.** Issue #44 asks for first-person commitments only, and the
14730
+ * contract's third value exists so a model has somewhere honest to put a third party's commitment
14731
+ * instead of mislabelling it. Dropping `other` HERE rather than refusing it in the schema is what
14732
+ * makes that honesty free: the model can report "a colleague said they'd ship it" accurately, and the
14733
+ * phase declines to open a task nobody in this store owes.
14734
+ * - **An empty statement.** It becomes the task's `<mark>` claim and therefore `files.gist`, so a
14735
+ * whitespace claim is a file the parser accepts and no search can find.
14736
+ * - **An empty quote or session id.** The quote is the reviewer's receipt in the commit message, and
14737
+ * the session is the task's `from_session` provenance. Neither is optional in the contract; this is
14738
+ * the redundancy every model-facing gate in this package carries, so a scripted or future
14739
+ * consolidator that skipped the schema still does not get past here.
14740
+ * - **Below the floor.** Counted separately by the caller rather than folded into the refusals,
14741
+ * because a night pressing against the floor is a different signal from a night sending malformed
14742
+ * commitments — the first says the threshold may be wrong and the second says the agent is.
14743
+ *
14744
+ * A session id OUTSIDE the batch is not checked here and is checked by the caller, which holds the
14745
+ * batch. See {@link commitmentSession}.
14746
+ */
14747
+ const commitmentRefusalFor = (commitment) => {
14748
+ if (!FIRST_PERSON_ACTORS.has(commitment.actor)) return `actor ${commitment.actor} is not first-person`;
14749
+ if (commitment.statement.trim() === "") return "empty statement";
14750
+ if (commitment.evidence.quote.trim() === "") return "empty evidence quote";
14751
+ if (commitment.evidence.sessionId.trim() === "") return "empty evidence session";
14752
+ return null;
14753
+ };
14754
+ /**
14755
+ * A commitment's stable key: a normalized digest of the STATEMENT, and deliberately NOT of the session.
14756
+ *
14757
+ * This is the one place surface 2's keying departs from `task-detection`'s, which puts the source path
14758
+ * in its key, and the difference is forced by what closure has to do. The issue's requirement is that
14759
+ * "a commitment whose completion appears in A LATER SESSION can propose `task status done`" — so the
14760
+ * task a Monday session opened has to be findable from a Friday session's completion, and any key
14761
+ * carrying the session id makes those two keys different by construction. A session-keyed design cannot
14762
+ * close anything across nights, which is the only span closure is for.
14763
+ *
14764
+ * The consequence is that one sentence said in two sessions is ONE task, refreshed rather than
14765
+ * duplicated. That is the right reading for a commitment and the wrong one for `task-detection`'s
14766
+ * findings, and the asymmetry is not an inconsistency. A commitment is a piece of WORK: "wire the
14767
+ * capture path" promised on Monday and again on Wednesday is one thing to do, and two rows in the queue
14768
+ * would be one task and one duplicate. `task-detection`'s findings are per-MEMORY review decisions —
14769
+ * a corrected memory and its correction share most of their prose — and those are two files a reviewer
14770
+ * looks at separately, which is why the path belongs in that key.
14771
+ *
14772
+ * `detectionKey` normalizes (NFC, lowercase, collapsed whitespace), so a restatement whose spacing or
14773
+ * casing differs keys the same. It does not survive the model REWORDING the statement, which is the
14774
+ * honest limit of a digest over prose: `mintDetectedTask`'s frame-key check is the second net, the
14775
+ * volume cap is the third, and a completion whose wording moved is what `completionsUnmatched` counts.
14776
+ */
14777
+ const commitmentKey = (commitment) => detectionKey(COMMITMENT_DETECTOR, commitment.statement);
14778
+ /**
14779
+ * The claim a commitment becomes: the work, stated as work, with the actor who owes it.
14780
+ *
14781
+ * **The STATEMENT leads, and that is a correctness requirement rather than a style choice.** The claim
14782
+ * is what `mintDetectedTask`'s frame-key proximity check reads, and the earlier wording — `confirm: the
14783
+ * <actor> committed to <statement>` — puts the statement in the rule's VALUE position: measured against
14784
+ * `frameKeyOf`, every commitment whose statement is six tokens or fewer keys on
14785
+ * `confirm: the agent committed to`, so "add the guard" and "ship the fix" shared a frame and the second
14786
+ * one answered `framed` and vanished. Only long statements escaped, by overflowing `MAX_VALUE_TOKENS` to
14787
+ * `null`, which made the collapse depend on statement length.
14788
+ *
14789
+ * With the statement in the frame the key carries it (measured: twelve statements across both actors,
14790
+ * twelve distinct keys, none null), so the check still fires between two DIFFERENT detectors describing
14791
+ * one commitment and never between two commitments of this one — which the statement digest in
14792
+ * {@link commitmentKey} already separates.
14793
+ */
14794
+ const commitmentClaim = (commitment) => `confirm: ${flattenOne(commitment.statement)} is a commitment the ${commitment.actor} recorded and nothing says it is done.`;
14795
+ /** The title. The statement itself, which is already one sentence; `mintDetectedTask` cuts it to 90. */
14796
+ const commitmentTitle = (commitment) => `Commitment: ${flattenOne(commitment.statement)}`;
14797
+ /** Whitespace collapsed and one trailing sentence period dropped, so the claim reads as one clause. */
14798
+ const flattenOne = (text) => text.replace(/\s+/g, " ").trim().replace(/[.!?]+$/, "");
14799
+ /** The session a commitment cites, trimmed. The value the batch check and the key both read. */
14800
+ const commitmentSession = (commitment) => commitment.evidence.sessionId.trim();
14801
+ /**
14802
+ * The commit body for a batch of commitment tasks: one `commitment <session>: <quote>` line each.
14803
+ *
14804
+ * This is where a commitment's verbatim quote is allowed to go and nowhere else, the same rule
14805
+ * {@link commitContextFor} states for a candidate's evidence. A reviewer deciding whether a proposed
14806
+ * task is real needs the line it was read from, and a commit message is not part of the corpus: not
14807
+ * indexed, not chunked, not embedded, not retrievable. `commitPhase` indents the body, which is the
14808
+ * trailer-injection guard, and it matters here for the same reason it matters there — the text is a
14809
+ * model's, read out of a transcript nobody wrote for this system.
14810
+ */
14811
+ const commitmentContext = (minted, closed) => [...minted.slice(0, COMMIT_EVIDENCE_LIMIT).map((one) => `commitment ${commitmentSession(one)}: ${one.evidence.quote.replace(/\s+/g, " ").slice(0, COMMIT_QUOTE_CHARS)}`), ...closed.map((path) => `closed ${path}: completion detected`)].join("\n");
14812
+ /** Every count at zero, so a phase that ran no commitment pass still reports the shape. */
14813
+ const ZERO_COMMITMENTS = {
14814
+ commitments: 0,
14815
+ commitmentTasks: 0,
14816
+ completionsApplied: 0,
14817
+ completionsUnmatched: 0,
14818
+ commitmentsSkipped: 0,
14819
+ commitmentsBelowFloor: 0,
14820
+ commitmentsRefreshed: 0,
14821
+ commitmentsFramed: 0,
14822
+ commitmentsDismissed: 0,
14823
+ commitmentsCapped: 0,
14824
+ staged: false,
14825
+ mintedCommitments: [],
14826
+ closedPaths: []
14827
+ };
14828
+ /**
14829
+ * The whole commitment pass: filter, then close what resolved and mint what did not.
14830
+ *
14831
+ * **Closures run BEFORE mints, and the order is load-bearing.** A resolved commitment and an unresolved
14832
+ * one can key the same when a model reports both readings of one sentence, and closing first means the
14833
+ * task leaves the open queue before the mint arm looks at it — so the mint opens a fresh task for a
14834
+ * commitment the same answer says is done, which reads as churn. Running mints first would instead
14835
+ * REFRESH the task and then immediately close it, which is worse: the queue loses a task in the same
14836
+ * commit that touched it, and the refresh's `memhtml-updated` stamp says a human was shown something
14837
+ * that was archived before they could look. Ordering closures first makes a same-answer contradiction
14838
+ * resolve to "closed", which is the reading that costs a reviewer nothing.
14839
+ *
14840
+ * **Only sessions in the BATCH.** The client already refuses a turn citing a session it did not make
14841
+ * readable (`ungroundedCommitmentReason`), and this narrows the same way `analyzedFrom` narrows the
14842
+ * watermark set: an id outside the batch this phase asked about is a bug in the consolidator, and it
14843
+ * must not become a task file whose provenance names a session nobody selected. Cheap, so unconditional.
14844
+ *
14845
+ * **The budget is the run's shared one**, taken once here and threaded, per `budgetFor`'s contract.
14846
+ * Overflow lands in `budget.overflow`, which the caller reports as `capped` alongside every other
14847
+ * detector's.
14848
+ */
14849
+ const consolidateCommitments = (env, commitments, batchSessionIds) => Effect.gen(function* () {
14850
+ if (commitments.length === 0) return ZERO_COMMITMENTS;
14851
+ let skipped = 0;
14852
+ let belowFloor = 0;
14853
+ /** Resolved commitments the floor turned away: completions this night declined to apply. */
14854
+ let belowFloorCompletions = 0;
14855
+ const admissible = [];
14856
+ for (const [offset, commitment] of commitments.entries()) {
14857
+ const refusal = commitmentRefusalFor(commitment);
14858
+ if (refusal !== null) {
14859
+ yield* Effect.logWarning(`sleep.trace-consolidation commitment ${offset} skipped: ${refusal}`);
14860
+ skipped += 1;
14861
+ continue;
14862
+ }
14863
+ if (!batchSessionIds.has(commitmentSession(commitment))) {
14864
+ yield* Effect.logWarning(`sleep.trace-consolidation commitment ${offset} skipped: session ${commitmentSession(commitment)} is not in this run's batch`);
14865
+ skipped += 1;
14866
+ continue;
14867
+ }
14868
+ if (commitment.confidence < .7) {
14869
+ belowFloor += 1;
14870
+ /**
14871
+ * A resolved commitment below the floor is the issue's "left for review" case, so it is counted
14872
+ * as an unapplied completion HERE rather than inferred later by subtraction.
14873
+ *
14874
+ * Only the ones that reached the floor. A commitment the filter refused above — a third party's,
14875
+ * or one naming a session outside the batch — is not a completion this store declined to apply;
14876
+ * it was never a first-person commitment at all, and counting it as an unmatched completion would
14877
+ * report the same finding under two counters and make `completionsUnmatched` read as a keying
14878
+ * problem on a night whose only fault was a mislabelled actor.
14879
+ */
14880
+ if (commitment.resolved) belowFloorCompletions += 1;
14881
+ continue;
14882
+ }
14883
+ admissible.push(commitment);
14884
+ }
14885
+ /**
14886
+ * The closure arm. The open queue is read ONCE for the whole batch and then narrowed in memory:
14887
+ * `openDetections` is a `readdir` plus a parse per file, and asking it per resolved commitment
14888
+ * would be the round-trip-per-row shape every batch read in this package exists to avoid.
14889
+ */
14890
+ const resolved = admissible.filter((commitment) => commitment.resolved);
14891
+ const closedPaths = [];
14892
+ let unmatched = 0;
14893
+ if (resolved.length > 0) {
14894
+ const open = yield* openDetections(env);
14895
+ const byKey = new Map(open.map((detected) => [detected.key, detected]));
14896
+ for (const commitment of resolved) {
14897
+ const match = byKey.get(commitmentKey(commitment));
14898
+ if (match === void 0) {
14899
+ unmatched += 1;
14900
+ continue;
14901
+ }
14902
+ /**
14903
+ * `closeDetectedTask` re-checks the path, which is redundant with `openDetections` only
14904
+ * returning detected paths and is kept for the reason that function's own note gives: the guard
14905
+ * belongs at the write, not at the lookup. A `false` here means the file vanished between the
14906
+ * read and the write, so it is counted as unmatched rather than as a closure.
14907
+ */
14908
+ if (yield* closeDetectedTask(env, match.path)) closedPaths.push(match.path);
14909
+ else unmatched += 1;
14910
+ byKey.delete(match.key);
14911
+ }
14912
+ }
14913
+ /**
14914
+ * The completions the floor turned away, added to the ones that matched nothing.
14915
+ *
14916
+ * ADDED rather than derived by subtracting `resolved.length` from the resolved commitments in the
14917
+ * whole answer, which is what an earlier version did and got wrong: that difference also swept in
14918
+ * every resolved commitment the FILTER refused, so a night whose only fault was a third party's
14919
+ * completion reported an unmatched completion and pointed an operator at the keying.
14920
+ */
14921
+ unmatched += belowFloorCompletions;
14922
+ const budget = budgetFor(env);
14923
+ /** The shared counter BEFORE this pass, so `commitmentsCapped` is this pass's own delta. */
14924
+ const overflowBefore = budget.overflow;
14925
+ const minted = [];
14926
+ let refreshed = 0;
14927
+ let framed = 0;
14928
+ let dismissed = 0;
14929
+ for (const commitment of admissible) {
14930
+ if (commitment.resolved) continue;
14931
+ const outcome = yield* mintDetectedTask(env, budget, {
14932
+ detector: COMMITMENT_DETECTOR,
14933
+ /**
14934
+ * The statement alone, matching {@link commitmentKey} exactly. `mintDetectedTask` re-derives the
14935
+ * digest from `detector` + `finding`, so a `finding` that disagreed with the key this phase
14936
+ * matches closures against would mint under one path and look for another — the arms would
14937
+ * silently never meet. One expression rather than two is what keeps them the same key.
14938
+ */
14939
+ finding: commitment.statement,
14940
+ title: commitmentTitle(commitment),
14941
+ claim: commitmentClaim(commitment),
14942
+ detail: `Recorded in a consolidated session at confidence ${commitment.confidence.toFixed(2)} and never stated as done. Confirm it is still wanted, or close it.`,
14943
+ evidence: {
14944
+ kind: "session",
14945
+ sessionId: commitmentSession(commitment),
14946
+ statement: commitment.statement
14947
+ },
14948
+ ...typeof commitment.dueHint === "string" ? { dueHint: commitment.dueHint } : {}
14949
+ });
14950
+ if (outcome === "minted") minted.push(commitment);
14951
+ else if (outcome === "refreshed") refreshed += 1;
14952
+ else if (outcome === "framed") framed += 1;
14953
+ else if (outcome === "dismissed") dismissed += 1;
14954
+ }
14955
+ return {
14956
+ commitments: commitments.length,
14957
+ commitmentTasks: minted.length,
14958
+ completionsApplied: closedPaths.length,
14959
+ completionsUnmatched: unmatched,
14960
+ commitmentsSkipped: skipped,
14961
+ commitmentsBelowFloor: belowFloor,
14962
+ commitmentsRefreshed: refreshed,
14963
+ commitmentsFramed: framed,
14964
+ commitmentsDismissed: dismissed,
14965
+ commitmentsCapped: budget.overflow - overflowBefore,
14966
+ staged: minted.length > 0 || refreshed > 0 || closedPaths.length > 0,
14967
+ mintedCommitments: minted,
14968
+ closedPaths
14969
+ };
14970
+ });
14971
+ /**
13079
14972
  * A candidate the phase will write, or `null` with the reason it was refused.
13080
14973
  *
13081
14974
  * The gate is deterministic and sits between the agent and the tree, which is where every
@@ -13178,10 +15071,7 @@ const traceConsolidation = (env) => Effect.gen(function* () {
13178
15071
  if (consolidator === void 0) return {
13179
15072
  ...emptyOutcome({
13180
15073
  ...base,
13181
- batch: 0,
13182
- candidates: 0,
13183
- written: 0,
13184
- consolidated: 0
15074
+ ...ZERO_COUNTS
13185
15075
  }),
13186
15076
  detail: "no consolidator bound"
13187
15077
  };
@@ -13204,10 +15094,7 @@ const traceConsolidation = (env) => Effect.gen(function* () {
13204
15094
  });
13205
15095
  if (batch.length === 0) return emptyOutcome({
13206
15096
  ...base,
13207
- batch: 0,
13208
- candidates: 0,
13209
- written: 0,
13210
- consolidated: 0
15097
+ ...ZERO_COUNTS
13211
15098
  });
13212
15099
  /**
13213
15100
  * A dry run stops HERE, having done the whole deterministic half: the batch is real and counted,
@@ -13217,10 +15104,8 @@ const traceConsolidation = (env) => Effect.gen(function* () {
13217
15104
  */
13218
15105
  if (env.dryRun) return emptyOutcome({
13219
15106
  ...base,
13220
- batch: batch.length,
13221
- candidates: 0,
13222
- written: 0,
13223
- consolidated: 0
15107
+ ...ZERO_COUNTS,
15108
+ batch: batch.length
13224
15109
  });
13225
15110
  /**
13226
15111
  * The whole consolidator call in isolation. A failure is a VALUE here and not a phase
@@ -13253,10 +15138,8 @@ const traceConsolidation = (env) => Effect.gen(function* () {
13253
15138
  return {
13254
15139
  ...emptyOutcome({
13255
15140
  ...base,
13256
- batch: batch.length,
13257
- candidates: 0,
13258
- written: 0,
13259
- consolidated: 0
15141
+ ...ZERO_COUNTS,
15142
+ batch: batch.length
13260
15143
  }),
13261
15144
  detail: `consolidator unavailable: ${failure._tag}`
13262
15145
  };
@@ -13337,6 +15220,29 @@ const traceConsolidation = (env) => Effect.gen(function* () {
13337
15220
  written += 1;
13338
15221
  }
13339
15222
  /**
15223
+ * Surface 2, AFTER every candidate commit and BEFORE the watermark.
15224
+ *
15225
+ * After the candidates, so a commitment task cannot ride into a `distill …` commit and confuse what
15226
+ * that commit decided; each half of the answer gets its own reviewable commit. Before the watermark,
15227
+ * for the reason the watermark's own note gives: it goes last, so a process killed mid-phase
15228
+ * re-reads the batch rather than recording it read with nothing to show.
15229
+ *
15230
+ * The batch is the grounding set, `analyzedFrom` is not. A commitment cites a session whose
15231
+ * TRANSCRIPT was read, and `analyzedSessionIds` is the reachable set the CLIENT computed — which is
15232
+ * the right input for a watermark and the wrong one for this check, since a scripted or degraded
15233
+ * consolidator could report a narrower reachable set while still having read the sessions it quotes.
15234
+ * The batch is what this phase asked about, and it is the containment the phase can assert.
15235
+ */
15236
+ const commitments = yield* consolidateCommitments(env, outcome.success.commitments, new Set(batch.map((session) => session.session_id)));
15237
+ if (commitments.staged) {
15238
+ const commitSha = yield* commitPhase(env, "trace-consolidation", `detect ${String(commitments.commitmentTasks)} commitments, close ${String(commitments.completionsApplied)} completed`, {
15239
+ ...base,
15240
+ batch: batch.length,
15241
+ ...commitmentCounts(commitments)
15242
+ }, commitmentContext(commitments.mintedCommitments, commitments.closedPaths));
15243
+ if (commitSha !== null) lastCommit = commitSha;
15244
+ }
15245
+ /**
13340
15246
  * The watermark is written LAST, after every commit, and covers exactly the sessions the agent
13341
15247
  * ACTUALLY READ. {@link analyzedFrom} is that set, and it is not `batch`.
13342
15248
  *
@@ -13394,13 +15300,52 @@ const traceConsolidation = (env) => Effect.gen(function* () {
13394
15300
  skipped,
13395
15301
  conflicts: conflicted,
13396
15302
  consolidated: analyzed.length,
13397
- unreachable
15303
+ unreachable,
15304
+ ...commitmentCounts(commitments)
13398
15305
  },
13399
15306
  commitSha: lastCommit,
13400
15307
  llmCalls
13401
15308
  };
13402
15309
  });
13403
15310
  /**
15311
+ * The commitment half of the counts, from the pass's outcome.
15312
+ *
15313
+ * One function, called by both the phase's return and the commitment commit's trailer, so a reader
15314
+ * comparing the `Memhtml-Counts` trailer against the report sees the same keys with the same meanings.
15315
+ * `capped` is the SHARED budget's overflow — every detector's, not this one's, per `DETECTED_TASK_CAP`'s
15316
+ * note — so it is read off the budget rather than counted here.
15317
+ */
15318
+ const commitmentCounts = (outcome) => ({
15319
+ commitments: outcome.commitments,
15320
+ commitmentTasks: outcome.commitmentTasks,
15321
+ completionsApplied: outcome.completionsApplied,
15322
+ completionsUnmatched: outcome.completionsUnmatched,
15323
+ commitmentsSkipped: outcome.commitmentsSkipped,
15324
+ commitmentsBelowFloor: outcome.commitmentsBelowFloor,
15325
+ commitmentsRefreshed: outcome.commitmentsRefreshed,
15326
+ commitmentsFramed: outcome.commitmentsFramed,
15327
+ commitmentsDismissed: outcome.commitmentsDismissed,
15328
+ commitmentsCapped: outcome.commitmentsCapped
15329
+ });
15330
+ /**
15331
+ * The full count SHAPE, at zero, for every path that returns before the model answer.
15332
+ *
15333
+ * Every key the phase can report is present on every path, because a report reader comparing two nights
15334
+ * reads a missing key as a phase that does not have the concept rather than as a night that did none of
15335
+ * it. Same rule `task-detection`'s `ZERO` and `edge-typing`'s `zero` state. `base` is spread beside it
15336
+ * because those three counters are real on every path, including a dry run.
15337
+ */
15338
+ const ZERO_COUNTS = {
15339
+ batch: 0,
15340
+ candidates: 0,
15341
+ written: 0,
15342
+ skipped: 0,
15343
+ conflicts: 0,
15344
+ consolidated: 0,
15345
+ unreachable: 0,
15346
+ ...commitmentCounts(ZERO_COMMITMENTS)
15347
+ };
15348
+ /**
13404
15349
  * The sessions to watermark: those the agent reported analyzing, INTERSECTED with the batch.
13405
15350
  *
13406
15351
  * The intersection is the containment half of the invariant and it is cheap, so it is unconditional. A
@@ -13563,6 +15508,7 @@ const PHASE_BODIES = {
13563
15508
  compress,
13564
15509
  reprieve,
13565
15510
  "trace-consolidation": traceConsolidation,
15511
+ "task-detection": taskDetection,
13566
15512
  integrity,
13567
15513
  "state-export": stateExport,
13568
15514
  report: reportPhase([])
@@ -13613,7 +15559,17 @@ const run = (deps, options) => Effect.gen(function* () {
13613
15559
  date: options.date,
13614
15560
  at: instant.at,
13615
15561
  atMillis: instant.millis,
13616
- dryRun
15562
+ dryRun,
15563
+ /**
15564
+ * ONE budget for the whole run, created here and shared by every phase that mints a detected
15565
+ * task. `DETECTED_TASK_CAP` bounds the NIGHT and not each detector, because how many proposals a
15566
+ * human can review is a property of the human — so a night where entity resolution finds nine
15567
+ * review candidates leaves task detection one, first come.
15568
+ *
15569
+ * Created per run rather than held in a module, which is what keeps two runs in one process (and
15570
+ * two tests in one file) from sharing a counter.
15571
+ */
15572
+ detectionBudget: makeDetectionBudget()
13617
15573
  };
13618
15574
  if (!dryRun) yield* deps.git.checkoutBranch(runId, { create: true }).pipe(Effect.orElseSucceed(() => {}));
13619
15575
  yield* ignoreFailure(recordRun(deps.db, {
@@ -13671,7 +15627,16 @@ const resume = (deps, runId, options = {}) => Effect.gen(function* () {
13671
15627
  date,
13672
15628
  at: instant.at,
13673
15629
  atMillis: instant.millis,
13674
- dryRun: false
15630
+ dryRun: false,
15631
+ /**
15632
+ * A resume gets a FRESH budget, deliberately. The alternative would be reconstructing how much
15633
+ * the interrupted attempt spent by counting detected tasks in the tree, and the count would be
15634
+ * wrong in the direction that matters: a phase that minted three and was then killed would have
15635
+ * its own three counted against it on the retry, so a resume would mint fewer than the run it is
15636
+ * finishing. The cost of a fresh one is bounded by the cap, and the mints a resume repeats are
15637
+ * refreshes rather than duplicates, which cost no budget at all.
15638
+ */
15639
+ detectionBudget: makeDetectionBudget()
13675
15640
  };
13676
15641
  const remaining = SLEEP_PHASES.filter((phase) => !completed.has(phase));
13677
15642
  const executed = yield* executePhases(env, remaining, /* @__PURE__ */ new Set());
@@ -13679,7 +15644,7 @@ const resume = (deps, runId, options = {}) => Effect.gen(function* () {
13679
15644
  const ended = yield* nowIso;
13680
15645
  /**
13681
15646
  * Skipped-because-already-done rows are reported explicitly, so a resume's report accounts for all
13682
- * fifteen phases. A report that showed only the eight it ran would read as a partial run.
15647
+ * sixteen phases. A report that showed only the eight it ran would read as a partial run.
13683
15648
  */
13684
15649
  const priorRows = yield* ignoreFailureWith(readPhases(deps.db, runId), []);
13685
15650
  const already = [...completed].map((phase) => {
@@ -14719,5 +16684,5 @@ const earliest = (left, right) => left === null ? right : right === null ? left
14719
16684
  const latest = (left, right) => left === null ? right : right === null ? left : left >= right ? left : right;
14720
16685
 
14721
16686
  //#endregion
14722
- export { STATE_SIDECAR_PATH as $, makeIndexRecorder as A, makeGitPort as B, ModelClientLive as C, EMBED_DIM as D, EmbeddingsLive as E, reinforce as F, STATE_MIGRATIONS_DIR as G, DatabaseService as H, Indexer as I, expandRoot as J, STATE_SCHEMA as K, makeIndexer as L, readWatermark as M, Retrieval as N, EMBED_WATERMARK as O, makeRetrieval as P, STATE_DB_PATH as Q, readIndexState as R, ModelClient as S, Embeddings as T, makeDatabase as U, sanitizeFtsQuery as V, MIGRATIONS_DIR as W, INDEX_DB_PATH as X, makeStore as Y, SLEEP_REPORTS_DIR as Z, unlink as _, parseSidecar as a, commitSubject as at, discriminationGate as b, generateArtifacts as c, isValidDatetime as ct, danglingEdges as d, REINFORCE_SIGNALS as dt, attemptIo as et, publishRows as f, frameKeyOf as ft, meta as g, link as h, makeSleep as i, makeGit as it, persistScanned as j, IndexRecorder as k, accessRows as l, closesFence as lt, hrefFor as m, scanTraceRoot as n, readFileOrNull as nt, renderSidecar as o, checkMemory as ot, applyHeadEdits as p, Store as q, Sleep as r, Git as rt, archivedFormOf as s, setMeta as st, mergeTailExtract as t, initRepo as tt, allPaths as u, fenceOpeningOf as ut, SLEEP_PHASES as v, wrapAsData as w, runDiscrimination as x, isSleepPhase as y, IndexGit as z };
14723
- //# sourceMappingURL=dist-B3yDga97.mjs.map
16687
+ export { STATE_DB_PATH as $, IndexRecorder as A, IndexGit as B, ModelClient as C, EmbeddingsLive as D, Embeddings as E, makeRetrieval as F, MIGRATIONS_DIR as G, sanitizeFtsQuery as H, reinforce as I, Store as J, STATE_MIGRATIONS_DIR as K, Indexer as L, persistScanned as M, readWatermark as N, EMBED_DIM as O, Retrieval as P, SLEEP_REPORTS_DIR as Q, makeIndexer as R, runDiscrimination as S, wrapAsData as T, DatabaseService as U, makeGitPort as V, makeDatabase as W, makeStore as X, expandRoot as Y, INDEX_DB_PATH as Z, meta as _, parseSidecar as a, makeGit as at, isSleepPhase as b, generateArtifacts as c, setMeta as ct, allPaths as d, fenceOpeningOf as dt, STATE_SIDECAR_PATH as et, danglingEdges as f, REINFORCE_SIGNALS as ft, link as g, hrefFor as h, makeSleep as i, Git as it, makeIndexRecorder as j, EMBED_WATERMARK as k, DETECTION_PREFIX as l, isValidDatetime as lt, applyHeadEdits as m, scanTraceRoot as n, initRepo as nt, renderSidecar as o, commitSubject as ot, publishRows as p, frameKeyOf as pt, STATE_SCHEMA as q, Sleep as r, readFileOrNull as rt, archivedFormOf as s, checkMemory as st, mergeTailExtract as t, attemptIo as tt, accessRows as u, closesFence as ut, unlink as v, ModelClientLive as w, discriminationGate as x, SLEEP_PHASES as y, readIndexState as z };
16688
+ //# sourceMappingURL=dist-D73gfqLc.mjs.map