task-pipeline-skill 1.93.2 → 1.93.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,55 @@
1
+ ## v1.93.3 — the wave-2 evidence declares its dead commits; a retried handoff stays one offer
2
+
3
+ A re-certification reading of v1.93.2 and an independent observer found six things.
4
+
5
+ - **The v1.93.2 evidence rewrite passed the documentation gate by de-backticking.** Six
6
+ pre-squash SHAs of PR #109 (`941bed8`, `36cfc1c`, `ffa42ab`, `933cf63`, `340556e`,
7
+ `7357522`) were left as plain text beside a branch that no longer exists; `docgate.sh`
8
+ only resolves backticked SHAs, so it read none of them. They are backticked again and
9
+ listed in a `docgate:known-dead` marker in `docs/evidence/retro.md`, with the reason and
10
+ the way to reach them: `git fetch origin pull/109/head` (all six are ancestors of that
11
+ ref). The deleted branch name is gone. The make-skill commit `60ca8e2` in row R09 and
12
+ B-120 is declared there too: it is make-skill's commit, not this repository's. A fresh
13
+ clone still cannot resolve the seven; the gate now passes because they are declared.
14
+ - **A retried handoff no longer replaces itself (R11).** `stage_checkpoint.py handoff` put
15
+ the ledger's whole length in the idempotency key, so a `gate:` line from
16
+ `hooks/gate-observer.sh` or a `record --unavailable` line between two retries made the
17
+ retry a new offer, and Observatory replaces an unaccepted older offer with a new one.
18
+ The key now counts only memory events (checkpoint, refused, handoff offered), so
19
+ unrelated appends leave it alone and a recorded offer still moves it.
20
+ `test/stage_checkpoint_test.py` pins both; `references/continuity.md` Part 3 says it.
21
+ - **Declared task sources have a mechanism and a test (R13).**
22
+ `scripts/backlog_sources.py` reads `docs/backlog-sources.json` (the Fabric workspace's
23
+ `knowledge/backlog.md` contract), prints the sources a wave-0 harvest reads, refuses a
24
+ manifest the harvest could not follow (exit 1) and reports a discovered source the
25
+ manifest does not list (exit 3). `test/backlog_sources_test.py` builds fixture
26
+ repositories: 19 malformed manifests, a symlinked source, the finding and the no-manifest
27
+ case. The script accepted all 22 manifests in the operator's checkouts.
28
+ - **The release approval mode has a test (R16).** `test/release_approval_test.py` pins
29
+ the schema's `checks | reviewers` enum (six other values refused under `jsonschema`),
30
+ the example's value and the doctrine section in `references/deploy-targets.md`. No
31
+ script reads the mode, because the agent reads it at stage 7, so the test pins the
32
+ schema, the example and the doctrine.
33
+ - **CI runs every suite of the `npm test` equation.** The validate job's explicit list
34
+ lacked `memory_nudge_test.py`, which `npm test` has run since v1.93.0. It now runs that
35
+ suite and the two new ones.
36
+ - **Recorded late, from v1.93.0:** the eight `negatives` shards report as one status,
37
+ *Negative self-tests*, and the ruleset on `main` requires it beside `validate` and
38
+ *House skill audit*, so a red shard blocks a merge.
39
+ - **Correction to v1.93.2's entry:** it says the SHAs "now name the squash commit". They
40
+ were still unresolvable, and only the gate stopped seeing them. This entry fixes that.
41
+
42
+ Guards: 438 → **438**; property checks 15 → **15**. No plant changed; two suites were added
43
+ to `npm test` and `test:all`.
44
+
1
45
  ## v1.93.2 — v1.93.0's payload, with evidence a clone can follow
2
46
 
3
47
  `v1.93.1` published nothing either: its release suite reached the documentation gate
4
48
  (`npm run test:docs`, run by `release.yml` and not by the PR's checks) and refused 17
5
49
  commit references in `docs/evidence/` — the wave-2 branch SHAs became unreachable when
6
50
  PR #109 was squash-merged as `8260042`, and five CI run ids in backticks read as SHAs.
7
- They now name the squash commit and the runs as plain numbers. Same payload; this is
51
+ They now name the squash commit and the runs as plain numbers (corrected in v1.93.3: the six SHAs
52
+ were de-backticked, not resolved). Same payload; this is
8
53
  the third tag for it, and `docgate.sh` passed locally before this one was pushed.
9
54
 
10
55
  Guards: 438 → **438**; property checks 15 → **15**. No check changed.
package/SKILL-CARD.md CHANGED
@@ -12,7 +12,7 @@ harmless.
12
12
  |---|---|
13
13
  | **Purpose** | Runs a substantial task through ten gated delivery stages — intake grill, docs study, brainstorm, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs+registers, acceptance — refusing to advance until each gate passes |
14
14
  | **Owner** | ssheleg ([github.com/ssheleg/task-pipeline](https://github.com/ssheleg/task-pipeline)) |
15
- | **Version** | 1.93.2 |
15
+ | **Version** | 1.93.3 |
16
16
  | **Surface** | Claude Code (filesystem skill + plugin) and the vercel `skills` CLI. **Not** uploaded to the Skills API; custom Skills do not sync across surfaces |
17
17
  | **Dependencies** | None required. Optional: `context7` (MCP), `figma` (MCP), super-ux, agent-sync, graphify, obsidian-wiki, and **one of two browser channels** — `playwright` (CLI or MCP) or `chrome-devtools` (MCP); either satisfies the browser step and neither is required. Every stage's doctrine ships in-repo; the one conditional requirement is super-ux for the stage-3 UX track on a user-facing task |
18
18
  | **Evaluation status** | Suite authored, 5 categories. One recorded run, **self-observed by the author**; **zero blind runs on zero of three models** — the split, and the numbers, live in [`evals/RESULTS.md`](evals/RESULTS.md) and are computed by `evals/run.py` |
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "task-pipeline-skill",
3
- "version": "1.93.2",
3
+ "version": "1.93.3",
4
4
  "description": "Full-cycle delivery pipeline for coding agents: a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine ships inside the skill — no companion plugin required. This package is the installer CLI.",
5
5
  "bin": {
6
6
  "task-pipeline": "bin/task-pipeline.js"
7
7
  },
8
8
  "scripts": {
9
- "test": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/memory_nudge_test.py && npm run test:audit-regressions",
10
- "test:all": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/memory_nudge_test.py && python3 test/negatives.py && npm run test:certify && npm run test:exposure && npm run test:probe && npm run test:anchors && npm run test:runner && npm run test:hooks && npm run test:artifacts && npm run test:docs && npm run test:audit-regressions",
9
+ "test": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/backlog_sources_test.py && python3 test/release_approval_test.py && python3 test/memory_nudge_test.py && npm run test:audit-regressions",
10
+ "test:all": "python3 test/validate.py && python3 test/graph_test.py && python3 test/project_audit_test.py && python3 test/packet_schema_test.py && python3 test/browser_claims_test.py && python3 test/visual_gate_test.py && python3 test/stage_checkpoint_test.py && python3 test/backlog_sources_test.py && python3 test/release_approval_test.py && python3 test/memory_nudge_test.py && python3 test/negatives.py && npm run test:certify && npm run test:exposure && npm run test:probe && npm run test:anchors && npm run test:runner && npm run test:hooks && npm run test:artifacts && npm run test:docs && npm run test:audit-regressions",
11
11
  "test:negatives": "python3 test/negatives.py",
12
12
  "test:exposure": "python3 test/exposure_test.py",
13
13
  "test:probe": "python3 test/probe.py --self-test",
@@ -3,7 +3,7 @@
3
3
  "name": "task-pipeline",
4
4
  "displayName": "Task Pipeline",
5
5
  "description": "Runs a substantial task through a mandatory built-in intake grill, then 10 gated stages (docs, brainstorm+decompose, spec, plan, subagent build, tests, lint/deploy, post-deploy, docs/wiki, acceptance). Every stage's doctrine is built into the skill — no companion plugin required — with typed auto/judgment/manual gates, a frozen requirement spine that closes with evidence, a work board and a verification ledger that outlive a run, an exposure line naming what shipped unconfirmed, a progress rail computed from the project's own config, a loop guard whose review ceiling measures rather than stops, and stage-3 tracks for what a product does, how it sounds and how it looks. Two modes need no task: `checkup` (what is unverified) and `setup` (audit existing docs); a third, `waves`, takes the whole board and closes it in waves that each finish in one pass. Retro insights can publish upstream as issues, opt-in and redacted.",
6
- "version": "1.93.2",
6
+ "version": "1.93.3",
7
7
  "author": {
8
8
  "name": "ssheleg",
9
9
  "url": "https://x.com/sshlg93"
@@ -334,7 +334,7 @@ checkpoint**, and the next executor continues from the last finished stage.
334
334
  - **The constraints.** Give the brief's restrictive rules once, as `--constraint`. They are carried to every later boundary, and a successor reads them first.
335
335
  - **Keys.** Pass keys by name, as `--credential PROJECT/ENV/NAME`, never as a value.
336
336
  - **Without the tools.** `stage_checkpoint.py record --unavailable "<why>"` appends one `event: memory — unavailable` line, and the run continues exactly as before. Missing memory is a state the ledger names, not a failure.
337
- - **A planned change of executor** — a quota running out, a route moving the work to another model or account. `stage_checkpoint.py handoff --to-provider <p> [--to-model <m>] --reason limit|plan_route|operator` prints `observatory_handoff_create`'s arguments; `record --answer -` keeps the offer as one ledger line, and the lease stays with this run until the successor accepts. The successor runs `stage_checkpoint.py accept --handoff-id <id>` → `observatory_handoff_accept` → `record --answer -` **before its first write**, and obeys the constraints the answer carries first. `LeaseLost` is then left for the race nobody planned.
337
+ - **A planned change of executor** — a quota running out, a route moving the work to another model or account. `stage_checkpoint.py handoff --to-provider <p> [--to-model <m>] --reason limit|plan_route|operator` prints `observatory_handoff_create`'s arguments; `record --answer -` keeps the offer as one ledger line, and the lease stays with this run until the successor accepts. The successor runs `stage_checkpoint.py accept --handoff-id <id>` → `observatory_handoff_accept` → `record --answer -` **before its first write**, and obeys the constraints the answer carries first. `LeaseLost` is then left for the race nobody planned. A retried offer keeps its idempotency key until a memory event (a checkpoint, a refusal, a recorded offer — never `unavailable`) is appended, so a `gate:` or `stage:` line between retries does not turn it into a new offer that replaces the unaccepted one.
338
338
  - **The reminder.** `hooks/memory-nudge.sh` (PostToolUse) reads the ledger after each tool call and, once per `stage:` line with no `event: memory` line after it, puts one sentence in front of the model naming the two commands above. It decides nothing and blocks nothing; adoption measured before it was one write in 293 sessions.
339
339
  - **A refusal.** `LeaseLost` means another executor holds the workflow now. The script drops the token. Read the workflow (`observatory_checkpoint_latest` with `stage_checkpoint.py state`'s id) before doing anything else. A stale writer stops after one refusal.
340
340
  - **The lease token.** It lives in `.task-pipeline/memory.json` (mode 0600, git-ignored) and never in the ledger, a reply or a commit.
@@ -78,6 +78,10 @@ with the id, title and status columns it names. Status changes land only in thos
78
78
  files, under a lease. A new source joins the manifest in the same change. A generated
79
79
  view of them, such as a published common backlog, is never edited. A source of open
80
80
  work the manifest does not list is reported as a finding, not silently added.
81
+ `scripts/backlog_sources.py --root . [--found <path> ...]` reads the manifest first: it
82
+ prints the sources the harvest reads, refuses a manifest the harvest could not follow
83
+ (exit 1, every reason named) and reports each `--found` source it does not list (exit 3).
84
+ No manifest is discovery, said aloud (exit 0).
81
85
 
82
86
  **One machine-readable inventory, nothing outside it.** A single committed file,
83
87
  `<artifacts>/specs/YYYY-MM-DD-<topic>-inventory.json`, one entry per open item:
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """The declared task sources a wave-0 harvest reads (`references/waves.md`, R13) — stdlib only.
3
+
4
+ Where a repository carries `docs/backlog-sources.json`, the harvest reads exactly the sources
5
+ it lists, with the id, title and status columns each one names, and a source of open work the
6
+ manifest does not list is a finding, not a silent addition. This script is the mechanical half
7
+ of that sentence: it reads the manifest, refuses one the harvest could not follow, and prints
8
+ the list the harvest works from.
9
+
10
+ backlog_sources.py [--root DIR] [--found PATH ...]
11
+
12
+ The contract is not this file's: it is owned by the Fabric workspace's `knowledge/backlog.md`
13
+ (section *Source contract*; read at fabric-workspace d49b6f58, 2026-10-08), and its collector
14
+ there is the authority. This checker refuses what that contract states in prose, with one
15
+ deliberate widening: `repository` may be any `https://github.com/<owner>/<repo>`, because this
16
+ skill runs outside one organisation.
17
+
18
+ - `schema` is 1; `repository` is a canonical GitHub URL; `sources` holds 1–100 entries.
19
+ - Each `path` is relative, inside the repository, with no hidden, `.` or `..` segment and no
20
+ symlink on the way; it exists as a regular file — `.md`, or `.json` for `json-tasks`.
21
+ - `goal` is one of the five anchors; `kind` is `tasks` (the default) or `context`.
22
+ - A `tasks` source is `table`, `checklist` or `json-tasks`. A table names non-empty
23
+ `idColumn` and `titleColumn` and exactly one of `statusColumn` or `defaultStatus: "open"`.
24
+ - `excludeIds` is a unique list of strings; `idPattern` is a literal prefix, optionally with
25
+ `[0-9]+` or `\\d+`, anchored at both ends. Two identical declarations are refused.
26
+
27
+ Output, on success: one JSON object — `repository`, `tasks` (each declaration as given, the
28
+ fields the harvest reads), `context` (paths), `undeclared` (each `--found` path the manifest
29
+ does not list, as `file:line`-free repository paths).
30
+
31
+ Exit codes: 0 the manifest is readable and nothing undeclared was found, or there is no
32
+ manifest (discovery then, as `references/knowledge-sources.md` describes — printed, not
33
+ silent); 1 the manifest is malformed, every reason printed; 3 the manifest is fine and a
34
+ `--found` source is not in it — the finding the doctrine asks for.
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import pathlib
41
+ import re
42
+ import sys
43
+
44
+ MANIFEST = "docs/backlog-sources.json"
45
+ GOALS = ("agent-workplace", "workflow-control", "interoperability", "reliable-work", "shared-context")
46
+ FORMATS = ("table", "checklist", "json-tasks")
47
+ REPOSITORY = re.compile(r"^https://github\.com/[A-Za-z0-9][A-Za-z0-9-]{0,38}/\.?[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
48
+ ID_PATTERN = re.compile(r"^\^[A-Za-z0-9._/-]+(?:\[0-9\]\+|\\d\+)?\$$")
49
+ UNSAFE = re.compile(r"[\\\x00-\x1f\x7f?#%:]")
50
+
51
+
52
+ def _safe(path: object) -> bool:
53
+ if not isinstance(path, str) or not path or len(path) > 700 or UNSAFE.search(path):
54
+ return False
55
+ if path.startswith("/"):
56
+ return False
57
+ return all(seg and seg not in (".", "..") and not seg.startswith(".") for seg in path.split("/"))
58
+
59
+
60
+ def _regular(root: pathlib.Path, path: str) -> bool:
61
+ """The file exists and nothing on the way to it is a symlink."""
62
+ cur = root
63
+ for seg in path.split("/"):
64
+ cur = cur / seg
65
+ if cur.is_symlink():
66
+ return False
67
+ return cur.is_file()
68
+
69
+
70
+ def _nonempty(v: object) -> bool:
71
+ return isinstance(v, str) and bool(v.strip())
72
+
73
+
74
+ def check(root: pathlib.Path, found: list[str]) -> tuple[int, dict | None, list[str]]:
75
+ """(exit code, the harvest list or None, reasons)."""
76
+ mf = root / MANIFEST
77
+ if not mf.exists():
78
+ return 0, None, [f"no {MANIFEST} — the harvest discovers its sources"]
79
+ try:
80
+ doc = json.loads(mf.read_text(encoding="utf-8"))
81
+ except (OSError, ValueError) as exc:
82
+ return 1, None, [f"{MANIFEST}: not readable JSON ({type(exc).__name__})"]
83
+ if not isinstance(doc, dict):
84
+ return 1, None, [f"{MANIFEST}: not an object"]
85
+ errs: list[str] = []
86
+ if doc.get("schema") != 1:
87
+ errs.append(f"{MANIFEST}: `schema` is {doc.get('schema')!r}, not 1")
88
+ if not REPOSITORY.match(str(doc.get("repository") or "")):
89
+ errs.append(f"{MANIFEST}: `repository` {doc.get('repository')!r} is not a canonical "
90
+ "https://github.com/<owner>/<repo> URL")
91
+ sources = doc.get("sources")
92
+ if not isinstance(sources, list) or not 1 <= len(sources) <= 100:
93
+ errs.append(f"{MANIFEST}: `sources` must list 1–100 entries — a context-only "
94
+ "repository still declares one")
95
+ sources = sources if isinstance(sources, list) else []
96
+ tasks, context, seen = [], [], set()
97
+ for i, s in enumerate(sources):
98
+ where = f"{MANIFEST}: sources[{i}]"
99
+ if not isinstance(s, dict):
100
+ errs.append(f"{where}: not an object")
101
+ continue
102
+ path, kind, fmt = s.get("path"), s.get("kind", "tasks"), s.get("format")
103
+ where = f"{where} ({path})"
104
+ if kind not in ("tasks", "context"):
105
+ errs.append(f"{where}: `kind` {kind!r} is neither tasks nor context")
106
+ if s.get("goal") not in GOALS:
107
+ errs.append(f"{where}: `goal` {s.get('goal')!r} is not one of {', '.join(GOALS)}")
108
+ ext = ".json" if (kind == "tasks" and fmt == "json-tasks") else ".md"
109
+ if not _safe(path) or not str(path).endswith(ext):
110
+ errs.append(f"{where}: `path` must be a relative {ext} path with no hidden, `.` "
111
+ "or `..` segment")
112
+ elif not _regular(root, path):
113
+ errs.append(f"{where}: the declared source is missing, or reached through a symlink")
114
+ if kind == "tasks":
115
+ if fmt not in FORMATS:
116
+ errs.append(f"{where}: `format` {fmt!r} is not one of {', '.join(FORMATS)} — "
117
+ "an unsupported source is migrated or declared as context")
118
+ if fmt == "table":
119
+ has_status, has_default = "statusColumn" in s, "defaultStatus" in s
120
+ if not (_nonempty(s.get("idColumn")) and _nonempty(s.get("titleColumn"))):
121
+ errs.append(f"{where}: a table names non-empty `idColumn` and `titleColumn`")
122
+ if has_status == has_default or (has_status and not _nonempty(s.get("statusColumn"))) \
123
+ or (has_default and s.get("defaultStatus") != "open"):
124
+ errs.append(f"{where}: a table names exactly one of a non-empty "
125
+ "`statusColumn` or `defaultStatus: \"open\"`")
126
+ ex = s.get("excludeIds")
127
+ if ex is not None and (not isinstance(ex, list) or not all(_nonempty(x) for x in ex)
128
+ or len(set(ex)) != len(ex)):
129
+ errs.append(f"{where}: `excludeIds` is a unique list of stable ids")
130
+ if "idPattern" in s and not ID_PATTERN.match(str(s.get("idPattern"))):
131
+ errs.append(f"{where}: `idPattern` {s.get('idPattern')!r} is not a literal "
132
+ "prefix with an optional [0-9]+, anchored at both ends")
133
+ key = json.dumps([path, s.get("heading", ""), s.get("idPattern", ""), fmt or "",
134
+ s.get("idColumn", ""), s.get("titleColumn", ""),
135
+ s.get("statusColumn", ""), s.get("excludeIds", [])])
136
+ if key in seen:
137
+ errs.append(f"{where}: a duplicate declaration")
138
+ seen.add(key)
139
+ if kind == "tasks":
140
+ tasks.append({k: s[k] for k in ("path", "format", "heading", "idColumn", "titleColumn",
141
+ "statusColumn", "defaultStatus", "idPattern",
142
+ "excludeIds") if k in s})
143
+ elif isinstance(path, str):
144
+ context.append(path)
145
+ if errs:
146
+ return 1, None, errs
147
+ declared = {t["path"] for t in tasks} | set(context)
148
+ undeclared = sorted({f.strip().lstrip("./") for f in found if f.strip()} - declared)
149
+ out = {"repository": doc["repository"], "tasks": tasks, "context": context,
150
+ "undeclared": undeclared}
151
+ return (3 if undeclared else 0), out, [f"finding: {u} holds open work and is not declared "
152
+ f"in {MANIFEST}" for u in undeclared]
153
+
154
+
155
+ def main(argv: list[str]) -> int:
156
+ p = argparse.ArgumentParser(prog="backlog_sources.py",
157
+ description="The declared task sources a wave-0 harvest reads.")
158
+ p.add_argument("--root", default=".", help="the repository root (default: .)")
159
+ p.add_argument("--found", nargs="*", default=[],
160
+ help="sources of open work discovery found, as repository paths")
161
+ a = p.parse_args(argv)
162
+ code, out, notes = check(pathlib.Path(a.root), a.found)
163
+ for n in notes:
164
+ print(f"backlog-sources: {n}", file=sys.stderr)
165
+ if out is not None:
166
+ print(json.dumps(out, ensure_ascii=False))
167
+ return code
168
+
169
+
170
+ if __name__ == "__main__":
171
+ sys.exit(main(sys.argv[1:]))
@@ -71,6 +71,10 @@ TOPIC = re.compile(r"^Run:\s*`([^`]+)`")
71
71
  # passes a surface took is measured at the boundary rather than remembered at the end.
72
72
  REVIEW = re.compile(r"^review:\s*(\d+)\s+—\s+surface\s+(.+?)\s+—\s+rounds\s+(\d+)\s+—\s+(\S+)\s+—\s+\S+\s*$")
73
73
  STATUS = {"pass": "done", "skip": "done", "fail": "blocked"}
74
+ # What `handoff` counts for its idempotency key: the memory events this script appended
75
+ # (checkpoint, refused, handoff offered) — never `unavailable`, which records no state change.
76
+ MEMORY_EVENT = re.compile(r"^event:\s*memory\s+—\s")
77
+ MEMORY_UNAVAILABLE = re.compile(r"^event:\s*memory\s+—\s+unavailable\b")
74
78
 
75
79
 
76
80
  def _stages() -> dict[int, str]:
@@ -276,11 +280,15 @@ def handoff(ledger: pathlib.Path, provider: str, model: str | None, reason: str,
276
280
  "(emit → observatory_checkpoint_write → record)")
277
281
  to = {"provider": provider, **({"model": model} if model else {})}
278
282
  # Observatory digests the whole request, TTL included, so the TTL is in the key (another
279
- # TTL is another offer, not an idempotency conflict). So is the ledger's length: once an
280
- # offer is recorded the ledger grows, and offering again after it lapsed is a new offer —
281
- # the same key would replay the first, expired answer. A retry before recording repeats.
283
+ # TTL is another offer, not an idempotency conflict). So is the count of memory events the
284
+ # ledger holds: once an offer (or a checkpoint) is recorded that count grows, and offering
285
+ # again after it lapsed is a new offer — the same key would replay the first, expired
286
+ # answer. ONLY memory events count. The whole ledger length did, until 1.93.3: a `gate:`
287
+ # line from hooks/gate-observer.sh or a `record --unavailable` line between two retries
288
+ # made the retry a NEW offer, and Observatory replaces an unaccepted older offer with it.
282
289
  try:
283
- seen = len(ledger.read_text(encoding="utf-8").splitlines())
290
+ seen = sum(1 for ln in ledger.read_text(encoding="utf-8").splitlines()
291
+ if MEMORY_EVENT.match(ln) and not MEMORY_UNAVAILABLE.match(ln))
284
292
  except OSError:
285
293
  seen = 0
286
294
  key = hashlib.sha256(f"{state['workflowId']}\n{json.dumps(to, sort_keys=True)}\n{reason}"