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 +46 -1
- package/SKILL-CARD.md +1 -1
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +4 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +171 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +12 -4
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
280
|
-
# offer is recorded
|
|
281
|
-
# the same key would replay the first, expired
|
|
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 =
|
|
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}"
|