task-pipeline-skill 1.93.2 → 1.94.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +151 -1
  2. package/README.md +42 -9
  3. package/SKILL-CARD.md +1 -1
  4. package/bin/task-pipeline.js +64 -33
  5. package/cursor/rules/task-pipeline.mdc +10 -5
  6. package/evals/RESULTS.md +1 -1
  7. package/evals/task-pipeline.evals.json +16 -0
  8. package/package.json +3 -3
  9. package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
  10. package/plugins/task-pipeline/agents/verifier.md +4 -1
  11. package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
  12. package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
  13. package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
  14. package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
  15. package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
  16. package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
  17. package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
  18. package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
  19. package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
  20. package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
  21. package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +95 -26
  22. package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
  23. package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
  24. package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
  25. package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
  26. package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
  27. package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
  28. package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
  29. package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
  30. package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
  31. package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
  32. package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
  33. package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +80 -36
  34. package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
  35. package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +212 -0
  36. package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
  37. package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
  38. package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
  39. package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +120 -15
  40. package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
  41. package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
  42. package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
  43. package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
  44. package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
  45. package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
  46. package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
  47. package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
  48. package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
  49. package/plugins/task-pipeline/skills/task-pipeline/templates/run.md +2 -2
@@ -0,0 +1,1137 @@
1
+ #!/usr/bin/env python3
2
+ """The local lesson index — every project's retro lessons, readable from any project.
3
+
4
+ A retro entry is written per project and read by that project's next stage 0. An agent on
5
+ another project never sees it, unless a wiki or a shared store happens to be installed. This
6
+ script is the floor every install has (`references/iteration.md` → *The learning loop*): a
7
+ retro entry that carries a `**Lesson:**` line is a lesson (`templates/lesson.schema.json`,
8
+ lesson/1), and the index projects every such entry from every project on this machine into
9
+ one file that stage 0 queries by the task's nouns.
10
+
11
+ **The retro stays canonical; the index is a projection.** It can be lost and rebuilt from
12
+ the retros (`rebuild`), and a rebuild equals the incremental index byte for byte, because an
13
+ entry is a pure function of its origins.
14
+
15
+ lessons.py add --retro FILE (--entry ANCHOR ... | --all) [--root DIR]
16
+ lessons.py add --json FILE|- [--root DIR]
17
+ validate lesson/1 and merge it into the index; a second project's copy of one
18
+ classKey makes it a candidate, a repeat from the same project revises, never mints
19
+ lessons.py query NOUN ... [--limit 5]
20
+ at most --limit lines: `[lesson L-<sha12>] title — trigger — check (seen N, projects K)`
21
+ lessons.py rebuild --roots DIR ... [--depth 3]
22
+ re-derive the whole index from every retro found under the roots; incomplete
23
+ discovery or an unreadable/invalid lesson source refuses without changing the index
24
+ lessons.py candidates [--min-projects 2]
25
+ lessons two or more projects hold: the `retro.publish` queue for learned.md
26
+ lessons.py show L-<sha12>
27
+ one entry, with its origins (pseudonymous project keys, commits, retro anchors)
28
+ lessons.py emit-observatory L-<sha12> [--tool observatory_record|observatory_lesson] [--root DIR]
29
+ the store's arguments for this project's origin, as JSON: `observatory_lesson` where the
30
+ session lists it, else `observatory_record` (the default; memoryId and expectedRevision
31
+ once recorded). The same pseudonymous project key, never a `project:<slug>` id; the
32
+ commit as `commit:` + 12 hex. The agent calls the tool
33
+ lessons.py record L-<sha12> --answer FILE|- [--ledger PATH] [--root DIR]
34
+ keep the store's memory id beside this project's origin; a refusal becomes one ledger
35
+ line (`event: lessons — store refused — <code>`) and the local entry stays
36
+ lessons.py path
37
+ the index this invocation would use
38
+
39
+ **Where the index lives.** `--index PATH`, else `$TASK_PIPELINE_LESSONS`, else
40
+ `~/.sshlg-skills/lessons/index.jsonl` when `~/.sshlg-skills` already exists — the family's
41
+ machine-state home, which hosts `lessons/` as task-pipeline's (sshlg-skills board row B-181;
42
+ its `docs/working-rules/state-json.md`, "Other members' files under ~/.sshlg-skills/",
43
+ ssheleg/sshlg-skills#209) — and `~/.task-pipeline/lessons/index.jsonl`
44
+ otherwise, so a standalone install never creates the family's directory. When the family's
45
+ directory appears later, the first run merges the standalone index into it once — copying
46
+ only lessons it lacks, the family copy winning on any lesson both hold, newer-format lines
47
+ carried verbatim — renames the old file `.migrated`, and says so.
48
+
49
+ **Every index operation runs under one lock** (`session`): the index directory's `.lock`
50
+ (`flock`; `$TASK_PIPELINE_LESSONS_LOCK_TIMEOUT` seconds, default 10), and on the default path
51
+ the standalone directory's `.lock` too, family first. Reads, writes, the merge, setting a
52
+ corrupt file aside: all under it. An index that does not parse — the family's or the
53
+ standalone one — is a derived file gone bad, never a reason to exit 1: it is kept as
54
+ `.corrupt-<date>[-n]` and the run goes on; `rebuild --roots` re-derives it. One that cannot be
55
+ READ is the one refusal, since its contents may be good. Directories this script creates are
56
+ 0700 (an existing one keeps its mode), the file 0600, written by a unique temp file and
57
+ `os.replace`. git gets
58
+ `$TASK_PIPELINE_GIT_TIMEOUT` seconds (default 60), and a git that does not answer is its own
59
+ refusal, never read as a missing commit.
60
+
61
+ **What a lesson may carry.** The fields of lesson/1 and nothing else. The text fields are one
62
+ line each and refused when they carry an absolute or home path, a URL, an e-mail address, a
63
+ credential shape or a long hex/base64 run: what a pattern can see. A RELATIVE path, a short
64
+ SHA or a repository name in text no pattern can tell from an ordinary word — those stay the
65
+ writer's duty, with names. A person's or a customer's
66
+ name a pattern cannot see; that stays the writer's duty (`references/retrospective.md` →
67
+ *What may leave the project*). The project is a pseudonymous key — `p-` and 12 hex digits
68
+ of the repository's root commit — never its name, and the evidence commit must resolve in
69
+ the repository it came from.
70
+
71
+ **The retro line it reads**, inside a `### ` entry of the retro or its archive:
72
+
73
+ - **Lesson:** `<class-key>` — <the class, one sentence> · **When:** <trigger> · **Nouns:** a, b, c
74
+
75
+ with the entry's own `**Surfaced at:** stage N · **Owned by:** stage M`, `**Fix:** grade G`,
76
+ `**The check:**` and `**Commit:** `<sha>`` lines. An entry with no Lesson line is private to
77
+ its project and is skipped, counted aloud.
78
+
79
+ Exit codes: 0 done (an absent index on `query` is a state, printed); 1 a refusal — a lesson
80
+ that fails lesson/1, a commit that does not resolve, a held lock, an unknown id; 2 usage or an
81
+ unreadable input. Stdlib only (`references/portability.md`).
82
+ """
83
+ from __future__ import annotations
84
+
85
+ import argparse
86
+ import hashlib
87
+ import json
88
+ import os
89
+ import pathlib
90
+ import re
91
+ import subprocess
92
+ import stat
93
+ import sys
94
+ import tempfile
95
+ import time
96
+ from datetime import datetime, timezone
97
+
98
+ OWNER = "agent:task-pipeline"
99
+ DEFAULT_LEDGER = ".task-pipeline/run.md"
100
+ ENV_INDEX = "TASK_PIPELINE_LESSONS"
101
+ ENV_LOCK = "TASK_PIPELINE_LESSONS_LOCK_TIMEOUT"
102
+ FIELDS = ("schema", "classKey", "title", "trigger", "check", "grade", "stages", "nouns", "origin")
103
+ TEXT_CAPS = {"title": (8, 160), "trigger": (3, 200), "check": (3, 200)}
104
+ CLASS_KEY = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
105
+ PROJECT = re.compile(r"^p-[0-9a-f]{12}$")
106
+ COMMIT = re.compile(r"^[0-9a-f]{7,40}$")
107
+ RETRO_REF = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_./-]*\.md#[^\W_]+(?:-[^\W_]+)*$")
108
+ LESSON_ID = re.compile(r"^L-[0-9a-f]{12}$")
109
+ # What a pattern can see. Each one names what it refuses, so a refusal is a sentence.
110
+ FORBIDDEN = (
111
+ (re.compile(r"(?:^|[\s\"'(`=:])(?:/[A-Za-z0-9._-]+){2,}"), "an absolute path"),
112
+ (re.compile(r"(?:^|[\s\"'(`=:])~[A-Za-z0-9._-]*/"), "a home path"),
113
+ (re.compile(r"(?:^|[\s\"'(`])[A-Za-z]:\\"), "a drive path"),
114
+ (re.compile(r"[a-z][a-z0-9+.-]*://", re.I), "a URL"),
115
+ (re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "an e-mail address"),
116
+ (re.compile(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"), "a credential shape (cloud access key)"),
117
+ (re.compile(r"\b(?:sk|pk|rk)-[A-Za-z0-9_-]{16,}"), "a credential shape (API key)"),
118
+ (re.compile(r"\b(?:ghp|gho|ghs|ghu|github_pat|xox[abpr]|glpat)[_-][A-Za-z0-9_-]{10,}"),
119
+ "a credential shape (service token)"),
120
+ (re.compile(r"-----BEGIN [A-Z ]+-----"), "a private key block"),
121
+ (re.compile(r"\b[0-9a-f]{32,}\b", re.I), "a long hex run (a hash or a secret: commits go in origin)"),
122
+ (re.compile(r"(?=[A-Za-z0-9+/_-]*[a-z])(?=[A-Za-z0-9+/_-]*[A-Z])(?=[A-Za-z0-9+/_-]*[0-9])"
123
+ r"[A-Za-z0-9+/_-]{40,}={0,2}"), "a long base64 run"),
124
+ )
125
+ ENTRY_HEAD = re.compile(r"^###\s+(.+?)\s*$")
126
+ LESSON_LINE = re.compile(
127
+ r"^\s*-\s+\*\*Lesson:\*\*\s*`([^`]+)`\s*[—-]\s*(.+?)\s*·\s*\*\*When:\*\*\s*(.+?)\s*·\s*\*\*Nouns:\*\*\s*(.+?)\s*$")
128
+ SURFACED = re.compile(r"\*\*Surfaced at:\*\*\s*stage\s+([^\s·,()]+)")
129
+ OWNED = re.compile(r"\*\*Owned by:\*\*\s*stage\s+([^\s·,()]+)")
130
+ GRADE = re.compile(r"\*\*Fix:\*\*\s*grade\s+([1-3])\b")
131
+ CHECK = re.compile(r"^\s*-\s+\*\*The check:\*\*\s*(.+)$")
132
+ COMMIT_LINE = re.compile(r"\*\*Commit:\*\*\s*`([0-9a-f]{7,40})`")
133
+ ARTIFACT_KNOWN = ("docs/evidence", "docs/superpowers")
134
+ ARTIFACT_REGISTERS = ("retro.md", "backlog.md", "verification.md", "specs", "plans", "briefs", "retro")
135
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build", ".next",
136
+ "target", ".cache"}
137
+
138
+
139
+ class Refused(Exception):
140
+ """A refusal the caller can act on: exit 1, one sentence, nothing written."""
141
+
142
+
143
+ # ---------------------------------------------------------------------------- paths
144
+
145
+
146
+ def index_path(given: str | None) -> pathlib.Path:
147
+ if given:
148
+ return pathlib.Path(given).expanduser()
149
+ env = os.environ.get(ENV_INDEX)
150
+ if env:
151
+ return pathlib.Path(env).expanduser()
152
+ # The family's machine-state directory when it exists — it hosts `lessons/` as
153
+ # task-pipeline's (sshlg-skills B-181; state-json.md "Other members' files under
154
+ # ~/.sshlg-skills/", #209) — otherwise task-pipeline's own, so a standalone install never
155
+ # creates the family's directory.
156
+ family = pathlib.Path.home() / ".sshlg-skills"
157
+ if family.is_dir():
158
+ return family / "lessons" / "index.jsonl"
159
+ return LEGACY_INDEX()
160
+
161
+
162
+ def LEGACY_INDEX() -> pathlib.Path:
163
+ return pathlib.Path.home() / ".task-pipeline" / "lessons" / "index.jsonl"
164
+
165
+
166
+ def _unique(path: pathlib.Path, suffix: str) -> pathlib.Path:
167
+ dest = path.with_name(f"{path.name}.{suffix}")
168
+ n = 1
169
+ while dest.exists():
170
+ n += 1
171
+ dest = path.with_name(f"{path.name}.{suffix}-{n}")
172
+ return dest
173
+
174
+
175
+ def set_aside(path: pathlib.Path) -> pathlib.Path:
176
+ """Rename a corrupt index to `<name>.corrupt-<UTC date>[-N]` beside it. Called only under
177
+ `session`'s lock, so no other run can have moved it first; an OSError is still a refusal
178
+ that names the file, never a traceback."""
179
+ dest = _unique(path, "corrupt-" + datetime.now(timezone.utc).strftime("%Y-%m-%d"))
180
+ try:
181
+ os.replace(path, dest)
182
+ except OSError as exc:
183
+ raise Refused(f"the corrupt index {path} could not be set aside ({type(exc).__name__}) — "
184
+ "nothing was written; move it away by hand")
185
+ return dest
186
+
187
+
188
+ def open_index(idx: pathlib.Path, notes: list[str]) -> dict[str, dict]:
189
+ """The index's entries — the same corrupt handling for the family index and the standalone
190
+ one: a file that does not parse is a DERIVED file gone bad, never a reason to exit 1. It is
191
+ set aside as `.corrupt-<date>[-n]` and the run goes on from empty; `rebuild --roots`
192
+ re-derives it. A file that cannot be READ (permissions) is the one refusal: its contents may
193
+ be good, so nothing replaces it."""
194
+ try:
195
+ return load(idx)
196
+ except IndexCorrupt:
197
+ dest = set_aside(idx)
198
+ notes.append(f"the index {idx} could not be parsed — kept as {dest.name}; "
199
+ "`lessons.py rebuild --roots <dir>` re-derives it from the retros")
200
+ _FUTURE[str(idx)] = []
201
+ return {}
202
+
203
+
204
+ def _merge_legacy(idx: pathlib.Path, notes: list[str]) -> None:
205
+ """Bring the standalone index into the family's, ONCE. Runs under BOTH directories' locks.
206
+
207
+ A merge never overwrites: only lessons the family index lacks are copied, the family copy
208
+ wins on any lesson in both, and newer-format lines from either file are carried verbatim.
209
+ The old file is then renamed `.migrated` — so the notice is printed once, by one run."""
210
+ legacy = LEGACY_INDEX()
211
+ if idx == legacy or not legacy.is_file():
212
+ return
213
+ try:
214
+ old = load(legacy)
215
+ except IndexCorrupt:
216
+ dest = set_aside(legacy)
217
+ notes.append(f"the old index {legacy} is corrupt — kept as {dest.name}, not merged; "
218
+ "`lessons.py rebuild --roots <dir>` re-derives the lessons from the retros")
219
+ return
220
+ except IndexUnreadable as exc:
221
+ notes.append(f"{exc} — the old index was not merged")
222
+ return
223
+ old_future = list(_FUTURE.get(str(legacy), []))
224
+ both = idx.exists()
225
+ new = open_index(idx, notes) if both else {}
226
+ copied = [k for k in sorted(old) if k not in new]
227
+ for k in copied:
228
+ new[k] = old[k]
229
+ future = list(_FUTURE.get(str(idx), [])) if both else []
230
+ future += [ln for ln in old_future if ln not in future]
231
+ _FUTURE[str(idx)] = future
232
+ save(idx, new)
233
+ dest = _unique(legacy, "migrated")
234
+ try:
235
+ os.replace(legacy, dest)
236
+ except OSError as exc:
237
+ raise Refused(f"merged the old index into {idx} but could not rename {legacy} "
238
+ f"({type(exc).__name__}) — remove it by hand so one index is read")
239
+ what = "both index files existed — read both: merged" if both else "moved the local index from"
240
+ notes.append(f"{what} {legacy} into {idx} — {len(copied)} of {len(old)} lessons copied, the "
241
+ f"family copy kept wherever both had one; the old file is kept as {dest.name}")
242
+
243
+
244
+ class session:
245
+ """Every index operation, under one lock. `with session(idx, default) as s:` takes the
246
+ index directory's lock — and, on the default path, the standalone directory's too (family
247
+ first, always in that order) — then merges the standalone index once, sets a corrupt index
248
+ aside, and loads. `s.entries` is the index; `s.save()` writes it. Notes print on exit."""
249
+
250
+ def __init__(self, idx: pathlib.Path, default: bool):
251
+ self.idx, self.default = idx, default
252
+ self.notes: list[str] = []
253
+ self.locks: list[locked] = []
254
+ self.entries: dict[str, dict] = {}
255
+
256
+ def __enter__(self):
257
+ dirs = [self.idx.parent]
258
+ legacy = LEGACY_INDEX()
259
+ if self.default and legacy != self.idx and legacy.parent.is_dir():
260
+ dirs.append(legacy.parent)
261
+ try:
262
+ for d in dirs:
263
+ lk = locked(d)
264
+ lk.__enter__()
265
+ self.locks.append(lk)
266
+ if self.default:
267
+ _merge_legacy(self.idx, self.notes)
268
+ self.entries = open_index(self.idx, self.notes)
269
+ except BaseException:
270
+ self._release()
271
+ raise
272
+ return self
273
+
274
+ def save(self) -> None:
275
+ save(self.idx, self.entries)
276
+
277
+ def _release(self) -> None:
278
+ for lk in reversed(self.locks):
279
+ lk.__exit__(None, None, None)
280
+ self.locks = []
281
+ for n in self.notes:
282
+ print(f"lessons: {n}", file=sys.stderr)
283
+ self.notes = []
284
+
285
+ def __exit__(self, *exc):
286
+ self._release()
287
+ return False
288
+
289
+
290
+ def _git(root: pathlib.Path, *args: str) -> str | None:
291
+ """git's answer, or None when git answered "no". A git that did not answer is NOT a
292
+ "no": until round 2 a timeout returned None too, so on a loaded machine a lesson was
293
+ refused as "no commit" or "does not resolve" — two of twelve verification runs failed
294
+ that way. A timeout or a missing git is its own refusal now."""
295
+ try:
296
+ timeout = float(os.environ.get("TASK_PIPELINE_GIT_TIMEOUT", "60"))
297
+ except ValueError:
298
+ timeout = 60.0
299
+ try:
300
+ r = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True,
301
+ timeout=timeout)
302
+ except subprocess.TimeoutExpired:
303
+ raise Refused(f"git {args[0]} did not answer within {timeout:g}s — the machine is "
304
+ "busy, not the lesson wrong; nothing was written, retry")
305
+ except OSError as exc:
306
+ raise Refused(f"git cannot be run ({type(exc).__name__}) — a lesson needs its commit")
307
+ return r.stdout.strip() if r.returncode == 0 else None
308
+
309
+
310
+ def repo_root(start: pathlib.Path) -> pathlib.Path:
311
+ top = _git(start, "rev-parse", "--show-toplevel")
312
+ if not top:
313
+ raise Refused(f"{start} is not inside a git repository — a lesson carries its evidence "
314
+ "commit, and a commit needs a repository")
315
+ return pathlib.Path(top)
316
+
317
+
318
+ def project_key(root: pathlib.Path) -> str:
319
+ """`p-` + 12 hex of the repository's root commit: stable across clones, names nothing."""
320
+ roots = _git(root, "rev-list", "--max-parents=0", "HEAD")
321
+ if not roots:
322
+ raise Refused(f"the repository at {root} has no commit — a project key needs one")
323
+ return "p-" + sorted(roots.split())[0][:12]
324
+
325
+
326
+ def commit_resolves(root: pathlib.Path, sha: str) -> bool:
327
+ return _git(root, "cat-file", "-e", f"{sha}^{{commit}}") is not None
328
+
329
+
330
+ def slug(text: str) -> str:
331
+ """An entry's anchor: lowercase letters and digits of any script, every run of other
332
+ characters one hyphen — a heading in Cyrillic keeps its words."""
333
+ return re.sub(r"[\W_]+", "-", text.lower()).strip("-")
334
+
335
+
336
+ def lesson_id(class_key: str) -> str:
337
+ return "L-" + hashlib.sha256(class_key.encode()).hexdigest()[:12]
338
+
339
+
340
+ def _now() -> str:
341
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
342
+
343
+
344
+ # ---------------------------------------------------------------------------- lesson/1
345
+
346
+
347
+ def _check_text(name: str, value: object, lo: int, hi: int) -> list[str]:
348
+ if not isinstance(value, str):
349
+ return [f"{name} is not a string"]
350
+ errs = []
351
+ if not lo <= len(value) <= hi:
352
+ errs.append(f"{name} is {len(value)} characters; lesson/1 allows {lo}-{hi}")
353
+ if "\n" in value or "\r" in value:
354
+ errs.append(f"{name} carries a line break — one line each")
355
+ for rx, what in FORBIDDEN:
356
+ if rx.search(value):
357
+ errs.append(f"{name} carries {what}")
358
+ return errs
359
+
360
+
361
+ def validate(lesson: object) -> list[str]:
362
+ """Every reason lesson/1 refuses this object, in one list (empty = valid)."""
363
+ if not isinstance(lesson, dict):
364
+ return ["a lesson is a JSON object"]
365
+ errs = [f"unknown field {k!r} — lesson/1 carries {', '.join(FIELDS)} and nothing else"
366
+ for k in lesson if k not in FIELDS]
367
+ errs += [f"missing field {k!r}" for k in FIELDS if k not in lesson]
368
+ if errs:
369
+ return errs
370
+ if lesson["schema"] != "lesson/1":
371
+ errs.append("schema is not lesson/1")
372
+ ck = lesson["classKey"]
373
+ if not isinstance(ck, str) or not CLASS_KEY.match(ck) or len(ck) > 64:
374
+ errs.append("classKey is not a slug (a-z, 0-9, single hyphens, at most 64)")
375
+ for name, (lo, hi) in TEXT_CAPS.items():
376
+ errs += _check_text(name, lesson[name], lo, hi)
377
+ g = lesson["grade"]
378
+ if not isinstance(g, int) or isinstance(g, bool) or not 1 <= g <= 3:
379
+ errs.append("grade is not an integer 1-3")
380
+ st = lesson["stages"]
381
+ if (not isinstance(st, dict) or set(st) != {"owned", "surfaced"}
382
+ or not all(isinstance(v, (int, str)) and not isinstance(v, bool) and str(v).strip()
383
+ for v in st.values())):
384
+ errs.append("stages is not {owned, surfaced}")
385
+ nouns = lesson["nouns"]
386
+ if (not isinstance(nouns, list) or not 3 <= len(nouns) <= 8
387
+ or len(set(map(str, nouns))) != len(nouns)):
388
+ errs.append("nouns is not 3-8 distinct strings")
389
+ else:
390
+ for n in nouns:
391
+ errs += _check_text("a noun", n, 2, 32)
392
+ o = lesson["origin"]
393
+ if not isinstance(o, dict) or set(o) != {"project", "commit", "retro"}:
394
+ errs.append("origin is not {project, commit, retro}")
395
+ else:
396
+ if not isinstance(o["project"], str) or not PROJECT.match(o["project"]):
397
+ errs.append("origin.project is not a pseudonymous key (p- and 12 hex)")
398
+ if not isinstance(o["commit"], str) or not COMMIT.match(o["commit"]):
399
+ errs.append("origin.commit is not a commit SHA")
400
+ r = o["retro"]
401
+ if (not isinstance(r, str) or not RETRO_REF.match(r) or len(r) > 200
402
+ or ".." in r.split("#")[0].split("/")):
403
+ errs.append("origin.retro is not a repository-relative retro file and #anchor")
404
+ return errs
405
+
406
+
407
+ def complete(lesson: dict, root: pathlib.Path) -> dict:
408
+ """Fill what the repository decides: the schema tag and the pseudonymous project key.
409
+
410
+ The key is ALWAYS this repository's. A supplied key that is well-formed and different
411
+ is refused, naming both: an add from one project claiming another's key would make one
412
+ project look like two, and `projects ≥ 2` is what turns a lesson into a candidate.
413
+ A malformed one is left for `validate` to name."""
414
+ out = dict(lesson)
415
+ out.setdefault("schema", "lesson/1")
416
+ if isinstance(out.get("origin"), dict):
417
+ o = dict(out["origin"])
418
+ mine = project_key(root)
419
+ given = o.get("project")
420
+ if isinstance(given, str) and PROJECT.match(given) and given != mine:
421
+ raise Refused(f"{out.get('classKey', '?')}: origin.project is {given}, but this "
422
+ f"repository's key is {mine} — a lesson is added from the project "
423
+ "that learned it; nothing was written")
424
+ o.setdefault("project", mine)
425
+ out["origin"] = o
426
+ return out
427
+
428
+
429
+ # ---------------------------------------------------------------------------- retro parsing
430
+
431
+
432
+ def _relative(root: pathlib.Path, path: pathlib.Path) -> str:
433
+ try:
434
+ return path.resolve().relative_to(root.resolve()).as_posix()
435
+ except ValueError:
436
+ raise Refused(f"{path} is outside the repository at {root}")
437
+
438
+
439
+ def parse_retro(path: pathlib.Path, root: pathlib.Path, project: str) -> tuple[list[dict], int, list[str]]:
440
+ """Lessons from one retro file: (lessons, entries without a Lesson line, refusals)."""
441
+ try:
442
+ text = path.read_text(encoding="utf-8")
443
+ except (OSError, UnicodeError) as exc:
444
+ raise Refused(f"{path} cannot be read ({type(exc).__name__})")
445
+ rel = _relative(root, path)
446
+ entries: list[tuple[str, list[str]]] = []
447
+ for line in text.splitlines():
448
+ h = ENTRY_HEAD.match(line)
449
+ if h:
450
+ entries.append((h.group(1), []))
451
+ elif line.startswith("## ") or line.startswith("# "):
452
+ entries.append(("", [])) # a section boundary ends the entry before it
453
+ elif entries:
454
+ entries[-1][1].append(line)
455
+ lessons, private, problems = [], 0, []
456
+ for head, body in entries:
457
+ if not head:
458
+ continue
459
+ ln = next((LESSON_LINE.match(b) for b in body if LESSON_LINE.match(b)), None)
460
+ if not ln:
461
+ private += 1
462
+ continue
463
+ blob = "\n".join(body)
464
+ check = None
465
+ for i, b in enumerate(body):
466
+ c = CHECK.match(b)
467
+ if c:
468
+ parts = [c.group(1).strip()]
469
+ for cont in body[i + 1:]: # a bullet wrapped onto indented lines
470
+ if cont.startswith(" ") and not cont.lstrip().startswith("- "):
471
+ parts.append(cont.strip())
472
+ else:
473
+ break
474
+ check = " ".join(parts)
475
+ break
476
+ sur, own, gr, cm = (SURFACED.search(blob), OWNED.search(blob), GRADE.search(blob),
477
+ COMMIT_LINE.search(blob))
478
+ anchor = slug(head)
479
+ missing = [n for n, v in (("Surfaced at", sur), ("Owned by", own), ("Fix: grade", gr),
480
+ ("The check", check), ("Commit", cm)) if not v]
481
+ if missing:
482
+ problems.append(f"{rel}#{anchor}: the Lesson line needs the entry's "
483
+ + ", ".join(missing))
484
+ continue
485
+
486
+ def stage(v: str) -> int | str:
487
+ return int(v) if v.isdigit() else v
488
+
489
+ lessons.append({
490
+ "schema": "lesson/1", "classKey": ln.group(1).strip(), "title": ln.group(2).strip(),
491
+ "trigger": ln.group(3).strip(), "check": check, "grade": int(gr.group(1)),
492
+ "stages": {"owned": stage(own.group(1)), "surfaced": stage(sur.group(1))},
493
+ "nouns": [n.strip() for n in ln.group(4).split(",") if n.strip()],
494
+ "origin": {"project": project, "commit": cm.group(1), "retro": f"{rel}#{anchor}"}})
495
+ return lessons, private, problems
496
+
497
+
498
+ # ---------------------------------------------------------------------------- the index
499
+
500
+
501
+ def _origin_key(o: dict) -> tuple:
502
+ return (o["project"], o["retro"])
503
+
504
+
505
+ def derive(origins: list[dict]) -> dict:
506
+ """One index entry as a pure function of its origins — so rebuild equals incremental."""
507
+ origins = sorted(origins, key=lambda o: (_origin_key(o), json.dumps(o, sort_keys=True)))
508
+ first = origins[0]["lesson"]
509
+ nouns = sorted({n for o in origins for n in o["lesson"]["nouns"]}, key=str.casefold)
510
+ projects = len({o["project"] for o in origins})
511
+ return {"schema": "lesson-index/1", "id": lesson_id(first["classKey"]),
512
+ "classKey": first["classKey"], "title": first["title"], "trigger": first["trigger"],
513
+ "check": first["check"], "grade": min(o["lesson"]["grade"] for o in origins),
514
+ "stages": first["stages"], "nouns": nouns, "seen": len(origins),
515
+ "projects": projects, "status": "candidate" if projects >= 2 else "local",
516
+ "origins": origins}
517
+
518
+
519
+ def _origin(lesson: dict, memory: str | None = None) -> dict:
520
+ o = lesson["origin"]
521
+ body = {k: lesson[k] for k in FIELDS if k not in ("schema", "origin")}
522
+ rec = {"project": o["project"], "commit": o["commit"], "retro": o["retro"], "lesson": body}
523
+ if memory:
524
+ rec["memory"] = memory
525
+ return rec
526
+
527
+
528
+ def merge(entries: dict[str, dict], lessons: list[dict]) -> int:
529
+ """Merge lessons into {classKey: entry}; returns how many origins changed."""
530
+ changed = 0
531
+ for le in lessons:
532
+ ck = le["classKey"]
533
+ origins = list(entries[ck]["origins"]) if ck in entries else []
534
+ key = (le["origin"]["project"], le["origin"]["retro"])
535
+ old = next((o for o in origins if _origin_key(o) == key), None)
536
+ new = _origin(le, old.get("memory") if old else None)
537
+ if old == new:
538
+ continue
539
+ origins = [o for o in origins if _origin_key(o) != key] + [new]
540
+ entries[ck] = derive(origins)
541
+ changed += 1
542
+ return changed
543
+
544
+
545
+ class IndexUnreadable(Refused):
546
+ """The file is there and cannot be READ (permissions, I/O). Its contents may be perfectly
547
+ good — memory ids included — so nothing ever replaces it: refuse, name it, write nothing."""
548
+
549
+
550
+ class IndexCorrupt(Refused):
551
+ """The file was read and does not parse as lesson-index/1. Only this one may be replaced,
552
+ and only after it is kept beside as `.corrupt-<date>`."""
553
+
554
+
555
+ # Lines in a NEWER format, per index path: skipped on read, counted, and written back on save,
556
+ # so an older script never destroys what a newer one wrote.
557
+ _FUTURE: dict[str, list[str]] = {}
558
+
559
+
560
+ def load(path: pathlib.Path) -> dict[str, dict]:
561
+ if not path.exists():
562
+ return {}
563
+ try:
564
+ text = path.read_text(encoding="utf-8")
565
+ except (OSError, UnicodeDecodeError) as exc:
566
+ if isinstance(exc, UnicodeDecodeError):
567
+ raise IndexCorrupt(f"the index {path} is not UTF-8 text — `lessons.py rebuild` "
568
+ "keeps it as .corrupt-<date> and re-derives it")
569
+ raise IndexUnreadable(f"the index {path} cannot be read ({type(exc).__name__}) — fix "
570
+ "its permissions; nothing was written")
571
+ out, future = {}, []
572
+ for i, line in enumerate(text.splitlines(), 1):
573
+ if not line.strip():
574
+ continue
575
+ try:
576
+ e = json.loads(line)
577
+ except ValueError:
578
+ raise IndexCorrupt(f"the index {path} does not parse (line {i}) — `lessons.py "
579
+ "rebuild` keeps it as .corrupt-<date> and re-derives it")
580
+ if isinstance(e, dict) and e.get("schema", "lesson-index/1") != "lesson-index/1":
581
+ future.append(line)
582
+ continue
583
+ if not isinstance(e, dict) or not isinstance(e.get("classKey"), str):
584
+ raise IndexCorrupt(f"the index {path} has a line that is not a lesson (line {i}) — "
585
+ "`lessons.py rebuild` keeps it as .corrupt-<date> and re-derives it")
586
+ out[e["classKey"]] = e
587
+ _FUTURE[str(path)] = future
588
+ if future:
589
+ print(f"lessons: {len(future)} line{'' if len(future) == 1 else 's'} in a newer format "
590
+ f"skipped in {path} (kept on write; a newer lessons.py reads them)", file=sys.stderr)
591
+ return out
592
+
593
+
594
+ def dump(entries: dict[str, dict], future: list[str] | None = None) -> str:
595
+ return ("".join(json.dumps(entries[k], ensure_ascii=False, sort_keys=True) + "\n"
596
+ for k in sorted(entries))
597
+ + "".join(line + "\n" for line in (future or [])))
598
+
599
+
600
+ class locked:
601
+ """An exclusive lock on an index DIRECTORY (`<dir>/.lock`), around every operation on any
602
+ file in it — the index, its `.migrated` and `.corrupt-*` copies (graph.py's `held`, with a
603
+ timeout: a writer that cannot get the lock refuses rather than waiting forever)."""
604
+
605
+ def __init__(self, directory: pathlib.Path):
606
+ self.lock = directory / ".lock"
607
+ self.fh = None
608
+
609
+ def __enter__(self):
610
+ _ensure_dir(self.lock.parent)
611
+ try:
612
+ import fcntl
613
+ except ImportError:
614
+ print("note: no file locking on this platform — a concurrent write could lose a "
615
+ "lesson", file=sys.stderr)
616
+ return self
617
+ try:
618
+ timeout = float(os.environ.get(ENV_LOCK, "10"))
619
+ except ValueError:
620
+ timeout = 10.0
621
+ try:
622
+ fd = os.open(self.lock, os.O_RDWR | os.O_CREAT, 0o600)
623
+ except OSError as exc:
624
+ raise Refused(f"the index lock {self.lock} cannot be opened ({type(exc).__name__}) "
625
+ "— nothing was written")
626
+ self.fh = os.fdopen(fd, "a+")
627
+ deadline = time.monotonic() + timeout
628
+ while True:
629
+ try:
630
+ fcntl.flock(self.fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
631
+ return self
632
+ except OSError:
633
+ if time.monotonic() >= deadline:
634
+ self.fh.close()
635
+ self.fh = None
636
+ raise Refused(f"the index is locked by another writer ({self.lock}) after "
637
+ f"{timeout:g}s — nothing was written; retry")
638
+ time.sleep(0.05)
639
+
640
+ def __exit__(self, *exc):
641
+ if self.fh:
642
+ self.fh.close()
643
+ return False
644
+
645
+
646
+ def _ensure_dir(d: pathlib.Path) -> None:
647
+ """Create the index directory 0700 — only the levels this script creates. A directory
648
+ that already exists keeps its mode: it may be shared, and it is not ours to tighten."""
649
+ missing = []
650
+ cur = d
651
+ while not cur.exists():
652
+ missing.append(cur)
653
+ cur = cur.parent
654
+ try:
655
+ for m in reversed(missing):
656
+ try:
657
+ m.mkdir(mode=0o700)
658
+ except FileExistsError:
659
+ # Another first writer made it between our look and our mkdir: theirs,
660
+ # used as it is. Until round 2 this race was a refusal.
661
+ continue
662
+ os.chmod(m, 0o700)
663
+ except OSError as exc:
664
+ raise Refused(f"the index directory {d} cannot be created ({type(exc).__name__}) — "
665
+ "nothing was written")
666
+
667
+
668
+ def save(path: pathlib.Path, entries: dict[str, dict]) -> None:
669
+ _ensure_dir(path.parent)
670
+ tmp = None
671
+ try:
672
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".index-", suffix=".tmp")
673
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
674
+ f.write(dump(entries, _FUTURE.get(str(path))))
675
+ os.chmod(tmp, 0o600)
676
+ os.replace(tmp, path)
677
+ except BaseException as exc:
678
+ if tmp is not None:
679
+ try:
680
+ os.unlink(tmp)
681
+ except OSError:
682
+ pass
683
+ if isinstance(exc, OSError):
684
+ raise Refused(f"the index {path} cannot be written ({type(exc).__name__}) — "
685
+ "the previous index is intact")
686
+ raise
687
+
688
+
689
+ def _resolve(entries: dict[str, dict], lid: str) -> dict:
690
+ if not LESSON_ID.match(lid):
691
+ raise Refused(f"{lid!r} is not a lesson id (L- and 12 hex)")
692
+ for e in entries.values():
693
+ if e["id"] == lid:
694
+ return e
695
+ raise Refused(f"no lesson {lid} in the index")
696
+
697
+
698
+ # ---------------------------------------------------------------------------- verbs
699
+
700
+
701
+ def cmd_add(a, idx: pathlib.Path) -> int:
702
+ root = repo_root(pathlib.Path(a.root or "."))
703
+ project = project_key(root)
704
+ private, problems = 0, []
705
+ if a.json is not None:
706
+ try:
707
+ raw = _stdin_utf8() if a.json == "-" else pathlib.Path(a.json).read_text(encoding="utf-8")
708
+ data = json.loads(raw)
709
+ except (OSError, ValueError) as exc:
710
+ print(f"lessons: the lesson JSON cannot be read ({type(exc).__name__})", file=sys.stderr)
711
+ return 2
712
+ lessons = [complete(x, root) for x in (data if isinstance(data, list) else [data])]
713
+ else:
714
+ retro = pathlib.Path(a.retro)
715
+ if not retro.is_absolute():
716
+ retro = pathlib.Path(a.root or ".").resolve() / retro
717
+ found, private, problems = parse_retro(retro, root, project)
718
+ if a.all:
719
+ lessons = found
720
+ else:
721
+ want = {slug(e) for e in a.entry}
722
+ lessons = [x for x in found if x["origin"]["retro"].split("#", 1)[1] in want]
723
+ missing = want - {x["origin"]["retro"].split("#", 1)[1] for x in found}
724
+ if missing:
725
+ raise Refused("no Lesson-bearing entry " + ", ".join(sorted(missing))
726
+ + f" in {retro.name} — the anchor is the heading's slug")
727
+ errors = []
728
+ for le in lessons:
729
+ errs = validate(le)
730
+ if not errs and not commit_resolves(root, le["origin"]["commit"]):
731
+ errs = [f"origin.commit {le['origin']['commit']} does not resolve in this repository"]
732
+ errors += [f"{le.get('classKey', '?')}: {e}" for e in errs]
733
+ errors += problems
734
+ if errors:
735
+ raise Refused("refused, nothing written:\n " + "\n ".join(errors))
736
+ with session(idx, a.default_index) as ss:
737
+ entries = ss.entries
738
+ changed = merge(entries, lessons)
739
+ if changed:
740
+ ss.save()
741
+ n = len(lessons)
742
+ note = (f" · {private} entr{'y carries' if private == 1 else 'ies carry'} no Lesson line "
743
+ "(project-private)") if private else ""
744
+ print(f"lessons: {n} lesson{'' if n == 1 else 's'} · {changed} changed{note}")
745
+ for le in lessons:
746
+ e = entries[le["classKey"]]
747
+ print(f" {e['id']} {e['classKey']} — {e['status']} (seen {e['seen']}, projects {e['projects']})")
748
+ return 0
749
+
750
+
751
+ def _words(text: str) -> set[str]:
752
+ return {w for w in re.findall(r"\w+", text.casefold()) if len(w) > 2}
753
+
754
+
755
+ def _hit(q: str, words: set[str]) -> bool:
756
+ # A cheap stem: Russian and English word forms share their first five letters often
757
+ # enough for a noun query; anything finer is a search engine this file will not be.
758
+ return q in words or (len(q) >= 5 and any(len(w) >= 5 and w[:5] == q[:5] for w in words))
759
+
760
+
761
+ def cmd_query(a, idx: pathlib.Path) -> int:
762
+ with session(idx, a.default_index) as ss:
763
+ entries = ss.entries
764
+ if not entries and not idx.exists():
765
+ print(f"lessons: local index absent ({idx}) — nothing to read; the retro and "
766
+ "learned.md still run")
767
+ return 0
768
+ query = [part.strip().casefold() for arg in a.nouns for part in arg.split(",") if part.strip()]
769
+ qwords = set(query) | {w for q in query for w in _words(q)}
770
+ scored = []
771
+ for e in entries.values():
772
+ nouns = {n.casefold() for n in e["nouns"]}
773
+ nwords = {w for n in nouns for w in _words(n)} | nouns
774
+ text = _words(" ".join((e["title"], e["trigger"], e["check"])))
775
+ score = 3 * sum(1 for q in qwords if _hit(q, nwords)) + sum(1 for q in qwords if _hit(q, text))
776
+ if score:
777
+ scored.append((-score, -e["projects"], -e["seen"], e["id"], e))
778
+ scored.sort(key=lambda t: t[:4])
779
+ limit = max(1, min(a.limit, 10))
780
+ for *_, e in scored[:limit]:
781
+ print(f"[lesson {e['id']}] {e['title']} — {e['trigger']} — {e['check']} "
782
+ f"(seen {e['seen']}, projects {e['projects']})")
783
+ print(f"lessons: {min(len(scored), limit)} of {len(scored)} match · {len(entries)} in the local index")
784
+ return 0
785
+
786
+
787
+ def _source_stat(path: pathlib.Path):
788
+ """Optional absent source, but never hide a dangling link or an unreadable parent.
789
+
790
+ Path.exists/is_file suppress different errors across Python versions. lstat names
791
+ the directory entry; stat follows a present link and must succeed. Ancestor admission
792
+ distinguishes a truly missing default tree from a broken link higher in that tree.
793
+ """
794
+ try:
795
+ info = path.lstat()
796
+ except FileNotFoundError:
797
+ if path.parent != path:
798
+ parent = _source_stat(path.parent)
799
+ if parent is not None and not stat.S_ISDIR(parent.st_mode):
800
+ raise Refused(f"{path.parent}: source parent is not a directory")
801
+ return None
802
+ if stat.S_ISLNK(info.st_mode):
803
+ try:
804
+ return path.stat()
805
+ except OSError as exc:
806
+ raise Refused(f"{path}: source link cannot be read ({type(exc).__name__})")
807
+ return info
808
+
809
+
810
+ def find_retros(roots: list[str], depth: int) -> tuple[list[tuple[pathlib.Path, pathlib.Path]], list[str]]:
811
+ """Discovery and its unknowns, kept separate from a readable empty scope."""
812
+ found, problems = [], []
813
+ def unread(exc):
814
+ problems.append(f"{exc.filename}: cannot discover ({type(exc).__name__})")
815
+ for r in roots:
816
+ base = pathlib.Path(r).expanduser()
817
+ try:
818
+ base_info = _source_stat(base)
819
+ if base_info is None or not stat.S_ISDIR(base_info.st_mode):
820
+ raise Refused(f"{base}: root is missing or not a readable directory")
821
+ except (OSError, Refused) as exc:
822
+ problems.append(str(exc))
823
+ continue
824
+ for cur, dirs, _files in os.walk(base, followlinks=True, onerror=unread):
825
+ here = pathlib.Path(cur)
826
+ rel_depth = len(here.relative_to(base).parts)
827
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
828
+ try:
829
+ if _source_stat(here / ".git") is not None:
830
+ art = _artifact_root(here)
831
+ archive = art / "retro"
832
+ archive_info = _source_stat(archive)
833
+ if archive_info is not None and not stat.S_ISDIR(archive_info.st_mode):
834
+ raise Refused(f"{archive}: retro archive is not a directory")
835
+ archived = sorted(archive.iterdir()) if archive_info is not None else []
836
+ files = [art / "retro.md"] + [p for p in archived if p.suffix == ".md"]
837
+ for f in files:
838
+ info = _source_stat(f)
839
+ if info is None:
840
+ continue
841
+ if not stat.S_ISREG(info.st_mode):
842
+ raise Refused(f"{f}: retro source is not a regular file")
843
+ found.append((here, f))
844
+ except (OSError, Refused) as exc:
845
+ problems.append(f"{here}: {exc}")
846
+ if rel_depth >= depth:
847
+ dirs[:] = []
848
+ return found, problems
849
+
850
+
851
+ def _artifact_root(repo: pathlib.Path) -> pathlib.Path:
852
+ """`paths.artifacts` in pipeline.json, else the first known root carrying a register."""
853
+ try:
854
+ config = repo / "pipeline.json"
855
+ info = _source_stat(config)
856
+ if info is not None and not stat.S_ISREG(info.st_mode):
857
+ raise Refused("pipeline.json is not a regular file")
858
+ conf = json.loads(config.read_text(encoding="utf-8")) if info is not None else {}
859
+ except (OSError, ValueError) as exc:
860
+ raise Refused(f"pipeline.json cannot be read ({type(exc).__name__})")
861
+ if not isinstance(conf, dict) or not isinstance(conf.get("paths", {}), dict):
862
+ raise Refused("pipeline.json must contain an object with an object paths field")
863
+ given = conf.get("paths", {}).get("artifacts")
864
+ if given is not None:
865
+ if not isinstance(given, str) or not given or pathlib.Path(given).is_absolute() or ".." in pathlib.Path(given).parts:
866
+ raise Refused("pipeline.json paths.artifacts is not a repository-relative directory")
867
+ art = repo / given
868
+ info = _source_stat(art)
869
+ if info is None or not stat.S_ISDIR(info.st_mode):
870
+ raise Refused(f"{art}: configured artifacts directory is missing or not a directory")
871
+ return art
872
+ # The same rule, names and registers as bin/lib/artifact-root.js and test/artifact_root.py:
873
+ # the first KNOWN name that exists AND carries one of the REGISTERS, else the default.
874
+ for name in ARTIFACT_KNOWN:
875
+ art = repo / name
876
+ info = _source_stat(art)
877
+ if info is not None and not stat.S_ISDIR(info.st_mode):
878
+ raise Refused(f"{art}: default artifacts path is not a directory")
879
+ if any(_source_stat(art / reg) is not None for reg in ARTIFACT_REGISTERS):
880
+ return art
881
+ return repo / ARTIFACT_KNOWN[0]
882
+
883
+
884
+ def cmd_rebuild(a, idx: pathlib.Path) -> int:
885
+ found, problems = find_retros(a.roots, a.depth)
886
+ lessons, private, unresolved, seen_files = [], 0, 0, 0
887
+ keys: dict[pathlib.Path, str] = {}
888
+ for repo, retro in found:
889
+ # Read the remaining sources to report every problem, but never replace a complete
890
+ # prior projection with an incomplete scan. The refusal precedes any index mutation.
891
+ try:
892
+ if repo not in keys:
893
+ keys[repo] = project_key(repo)
894
+ got, priv, probs = parse_retro(retro, repo, keys[repo])
895
+ mine, unres = [], 0
896
+ for le in got:
897
+ if validate(le):
898
+ probs.append(f"{le['classKey']}: refused by lesson/1")
899
+ elif not commit_resolves(repo, le["origin"]["commit"]):
900
+ unres += 1
901
+ probs.append(f"{le['classKey']}: evidence commit does not resolve")
902
+ else:
903
+ mine.append(le)
904
+ except Refused as exc:
905
+ problems.append(f"{repo.name}: {exc}")
906
+ continue
907
+ seen_files += 1
908
+ private += priv
909
+ unresolved += unres
910
+ lessons += mine
911
+ problems += [f"{repo.name}: {p}" for p in probs]
912
+ if problems:
913
+ for problem in problems:
914
+ print(f" not read: {problem}")
915
+ print("lessons: incomplete rebuild refused — index and migration state unchanged", file=sys.stderr)
916
+ return 1
917
+ with session(idx, a.default_index) as ss:
918
+ # The previous index lends its memory ids. `session` already refused an unreadable one
919
+ # (its contents may be good) and set a corrupt one aside; its notes go to stderr as for
920
+ # every verb, and into this verb's report as well.
921
+ old = ss.entries
922
+ problems += [f"the previous index: {n}" for n in ss.notes]
923
+ memory = {(o["project"], o["retro"]): o["memory"]
924
+ for e in old.values() for o in e["origins"] if o.get("memory")}
925
+ entries: dict[str, dict] = {}
926
+ # Worktrees of one project hold the same entries: one origin per (project, anchor),
927
+ # and where two copies differ the order below decides, the same way every time.
928
+ for le in sorted(lessons, key=lambda x: json.dumps(x, sort_keys=True)):
929
+ merge(entries, [le])
930
+ for e in entries.values():
931
+ origins = [dict(o, memory=memory[_origin_key(o)]) if _origin_key(o) in memory else o
932
+ for o in e["origins"]]
933
+ entries[e["classKey"]] = derive(origins)
934
+ ss.entries = entries
935
+ ss.save()
936
+ print(f"lessons: rebuilt from {seen_files} retro file{'s' if seen_files != 1 else ''} in "
937
+ f"{len(keys)} repositor{'y' if len(keys) == 1 else 'ies'} · {len(entries)} lessons · "
938
+ f"{private} private entries skipped · {unresolved} with a commit that does not resolve")
939
+ for p in problems:
940
+ print(f" not read: {p}")
941
+ return 0
942
+
943
+
944
+ def cmd_candidates(a, idx: pathlib.Path) -> int:
945
+ with session(idx, a.default_index) as ss:
946
+ entries = ss.entries
947
+ hits = sorted((e for e in entries.values() if e["projects"] >= a.min_projects),
948
+ key=lambda e: (-e["projects"], e["classKey"]))
949
+ for e in hits:
950
+ print(f"{e['id']} {e['classKey']} — {e['title']} (projects {e['projects']}, seen {e['seen']})")
951
+ print(f"lessons: {len(hits)} candidate{'' if len(hits) == 1 else 's'} for learned.md "
952
+ f"(projects ≥ {a.min_projects}) · {len(entries)} in the local index")
953
+ return 0
954
+
955
+
956
+ def _this_origin(e: dict, root: pathlib.Path) -> dict:
957
+ project = project_key(root)
958
+ mine = [o for o in e["origins"] if o["project"] == project]
959
+ if not mine:
960
+ raise Refused(f"{e['id']} has no origin in this repository — emit and record from the "
961
+ "project that learned it")
962
+ return sorted(mine, key=_origin_key)[0]
963
+
964
+
965
+ def cmd_emit(a, idx: pathlib.Path) -> int:
966
+ """The shared store's arguments for this project's origin.
967
+
968
+ Two routes, chosen by the agent from the tools its session lists — this script cannot see
969
+ them offline, so the choice is `--tool`, defaulting to the one every engine has:
970
+ - `observatory_lesson` where the session lists it (the engine's lesson kind): exactly
971
+ `owner, classKey, title, trigger, check, nouns, origin{projectId, commit}`, the engine's
972
+ published inputSchema; idempotent on (classKey, projectId) in the store itself.
973
+ - `observatory_record` otherwise: a note whose statement starts `lesson:`, so a later
974
+ migration finds every one; a recorded lesson is revised (memoryId, expectedRevision).
975
+ Either way the project travels as the same pseudonymous key the index holds, never a
976
+ `project:<slug>` id, and the commit as `commit:` + 12 hex — the store's redactor removes a
977
+ 40-hex SHA as a credential shape and keeps exactly that short form."""
978
+ with session(idx, a.default_index) as ss:
979
+ e = _resolve(ss.entries, a.id)
980
+ o = _this_origin(e, repo_root(pathlib.Path(a.root or ".")))
981
+ le = o["lesson"]
982
+ if a.tool == "observatory_lesson":
983
+ args = {"owner": OWNER, "classKey": le["classKey"], "title": le["title"],
984
+ "trigger": le["trigger"], "check": le["check"], "nouns": le["nouns"],
985
+ "origin": {"projectId": o["project"], "commit": "commit:" + o["commit"][:12]}}
986
+ print(json.dumps({"tool": "observatory_lesson", "arguments": args}, ensure_ascii=True))
987
+ return 0
988
+ args = {"owner": OWNER, "statement": f"lesson: {le['classKey']} — {le['title']}",
989
+ "why": f"{le['trigger']}; check: {le['check']}",
990
+ "evidence": [{"kind": "commit", "ref": "commit:" + o["commit"][:12]},
991
+ {"kind": "lesson", "id": e["id"], "classKey": le["classKey"],
992
+ "project": o["project"], "nouns": le["nouns"]}]}
993
+ mem = re.match(r"^(mem:[^@\s]+)@([0-9]+)$", o.get("memory") or "")
994
+ if mem:
995
+ args["memoryId"], args["expectedRevision"] = mem.group(1), int(mem.group(2))
996
+ # ASCII-escaped JSON: lossless whatever the locale's codec (a '?' in a lesson's title
997
+ # would be stored in the shared store as the lesson).
998
+ print(json.dumps({"tool": "observatory_record", "arguments": args}, ensure_ascii=True))
999
+ return 0
1000
+
1001
+
1002
+ def _stdin_utf8() -> str:
1003
+ """stdin as UTF-8, whatever the locale: JSON is UTF-8 by definition, and decoding it in a
1004
+ Latin-1 locale stored mojibake in the index (verification round 3)."""
1005
+ return sys.stdin.buffer.read().decode("utf-8")
1006
+
1007
+
1008
+ def _ledger_line(ledger: pathlib.Path | None, line: str) -> None:
1009
+ if ledger is None:
1010
+ return
1011
+ ledger.parent.mkdir(parents=True, exist_ok=True)
1012
+ with ledger.open("a", encoding="utf-8") as f:
1013
+ f.write(line + "\n")
1014
+
1015
+
1016
+ def cmd_record(a, idx: pathlib.Path) -> int:
1017
+ root = repo_root(pathlib.Path(a.root or "."))
1018
+ ledger = pathlib.Path(a.ledger) if a.ledger else (root / DEFAULT_LEDGER)
1019
+ ledger = ledger if ledger.exists() or a.ledger else None
1020
+ try:
1021
+ raw = _stdin_utf8() if a.answer == "-" else pathlib.Path(a.answer).read_text(encoding="utf-8")
1022
+ answer = json.loads(raw)
1023
+ except (OSError, ValueError) as exc:
1024
+ print(f"lessons: the answer cannot be read ({type(exc).__name__})", file=sys.stderr)
1025
+ return 2
1026
+ if not isinstance(answer, dict):
1027
+ print("lessons: the answer is not a JSON object", file=sys.stderr)
1028
+ return 2
1029
+ with session(idx, a.default_index) as ss:
1030
+ entries = ss.entries
1031
+ e = _resolve(entries, a.id)
1032
+ o = _this_origin(e, root)
1033
+ if answer.get("error"):
1034
+ code = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(answer["error"]))[:60] or "unknown"
1035
+ line = f"event: lessons — store refused — {code} — {e['id']} kept locally — {_now()}"
1036
+ _ledger_line(ledger, line)
1037
+ print(line)
1038
+ return 0
1039
+ mid = answer.get("memoryId") or answer.get("memory_id") or answer.get("id")
1040
+ # Absent means the store's first revision; a PRESENT 0 is a malformed answer, not 1.
1041
+ rev = answer["revision"] if "revision" in answer else (answer.get("record") or {}).get("revision", 1)
1042
+ if not isinstance(mid, str) or not mid.startswith("mem:") or re.search(r"[@\s]", mid):
1043
+ print("lessons: the answer names no memory id (mem:…)", file=sys.stderr)
1044
+ return 2
1045
+ if isinstance(rev, bool) or not (
1046
+ (isinstance(rev, int) and rev >= 1)
1047
+ or (isinstance(rev, str) and re.fullmatch(r"[1-9][0-9]*", rev, re.ASCII))):
1048
+ print(f"lessons: the answer's revision {rev!r} is not a whole number from 1",
1049
+ file=sys.stderr)
1050
+ return 2
1051
+ memory = f"{mid}@{int(rev)}"
1052
+ origins = [dict(x, memory=memory) if _origin_key(x) == _origin_key(o) else x
1053
+ for x in e["origins"]]
1054
+ entries[e["classKey"]] = derive(origins)
1055
+ ss.save()
1056
+ line = f"event: lessons — recorded {e['id']} as {memory} — {_now()}"
1057
+ _ledger_line(ledger, line)
1058
+ print(line)
1059
+ return 0
1060
+
1061
+
1062
+ def cmd_show(a, idx: pathlib.Path) -> int:
1063
+ with session(idx, a.default_index) as ss:
1064
+ e = _resolve(ss.entries, a.id)
1065
+ print(json.dumps(e, ensure_ascii=True, indent=1, sort_keys=True))
1066
+ return 0
1067
+
1068
+
1069
+ def main(argv: list[str]) -> int:
1070
+ # Titles and anchors may be in any script, and the messages carry dashes: under a locale
1071
+ # that cannot encode them, print() raised and the verb exited 1 for no reason of its own.
1072
+ # Human lines degrade to '?' there; what a machine reads (`emit-observatory`, `show`) is
1073
+ # ASCII-escaped JSON and stdin is read as UTF-8, so nothing a lesson carries is lost.
1074
+ for stream in (sys.stdout, sys.stderr):
1075
+ try:
1076
+ stream.reconfigure(errors="replace")
1077
+ except (AttributeError, ValueError):
1078
+ pass
1079
+ common = argparse.ArgumentParser(add_help=False)
1080
+ common.add_argument("--index", help=f"the index file; default ${ENV_INDEX}, else "
1081
+ "~/.sshlg-skills/lessons/index.jsonl where "
1082
+ "~/.sshlg-skills exists, else "
1083
+ "~/.task-pipeline/lessons/index.jsonl")
1084
+ p = argparse.ArgumentParser(prog="lessons.py", description=__doc__.split("\n")[0])
1085
+ sub = p.add_subparsers(dest="cmd", required=True)
1086
+ ad = sub.add_parser("add", parents=[common], help="merge lessons into the index")
1087
+ src = ad.add_mutually_exclusive_group(required=True)
1088
+ src.add_argument("--retro", help="a retro or archive file of this repository")
1089
+ src.add_argument("--json", help="a lesson/1 object or list, a file or - for stdin")
1090
+ ad.add_argument("--entry", action="append", default=[], help="an entry's anchor; repeatable")
1091
+ ad.add_argument("--all", action="store_true", help="every Lesson-bearing entry of --retro")
1092
+ ad.add_argument("--root", help="the repository (default: the current directory)")
1093
+ q = sub.add_parser("query", parents=[common], help="lessons that fire on the task's nouns")
1094
+ q.add_argument("nouns", nargs="+")
1095
+ q.add_argument("--limit", type=int, default=5)
1096
+ rb = sub.add_parser("rebuild", parents=[common], help="re-derive the index from the retros")
1097
+ rb.add_argument("--roots", nargs="+", required=True)
1098
+ rb.add_argument("--depth", type=int, default=3, help="directory levels below each root")
1099
+ c = sub.add_parser("candidates", parents=[common], help="lessons two or more projects hold")
1100
+ c.add_argument("--min-projects", type=int, default=2)
1101
+ sh = sub.add_parser("show", parents=[common], help="one entry with its origins")
1102
+ sh.add_argument("id")
1103
+ em = sub.add_parser("emit-observatory", parents=[common], help="the shared store's arguments")
1104
+ em.add_argument("id")
1105
+ em.add_argument("--tool", default="observatory_record",
1106
+ choices=["observatory_record", "observatory_lesson"],
1107
+ help="observatory_lesson where the session lists it, else observatory_record")
1108
+ em.add_argument("--root")
1109
+ rc = sub.add_parser("record", parents=[common], help="keep the store's answer")
1110
+ rc.add_argument("id")
1111
+ rc.add_argument("--answer", required=True, help="the tool's JSON answer, a file or -")
1112
+ rc.add_argument("--ledger", help=f"the run ledger (default {DEFAULT_LEDGER} when it exists)")
1113
+ rc.add_argument("--root")
1114
+ sub.add_parser("path", parents=[common], help="the index this invocation would use")
1115
+ a = p.parse_args(argv)
1116
+ if a.cmd == "add" and a.retro and not (a.all or a.entry):
1117
+ p.error("add --retro needs --entry ANCHOR or --all")
1118
+ if a.cmd == "rebuild" and a.depth < 0:
1119
+ p.error("rebuild --depth must be non-negative")
1120
+ idx = index_path(a.index)
1121
+ if a.cmd == "path":
1122
+ print(idx)
1123
+ return 0
1124
+ # The default path (no --index, no $TASK_PIPELINE_LESSONS) is the one a standalone index
1125
+ # may still need merging into: `session` does it, once, under both directories' locks.
1126
+ a.default_index = not a.index and not os.environ.get(ENV_INDEX)
1127
+ verbs = {"add": cmd_add, "query": cmd_query, "rebuild": cmd_rebuild, "candidates": cmd_candidates,
1128
+ "show": cmd_show, "emit-observatory": cmd_emit, "record": cmd_record}
1129
+ try:
1130
+ return verbs[a.cmd](a, idx)
1131
+ except Refused as exc:
1132
+ print(f"lessons: {exc}", file=sys.stderr)
1133
+ return 1
1134
+
1135
+
1136
+ if __name__ == "__main__":
1137
+ sys.exit(main(sys.argv[1:]))