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.
- package/CHANGELOG.md +151 -1
- package/README.md +42 -9
- package/SKILL-CARD.md +1 -1
- package/bin/task-pipeline.js +64 -33
- package/cursor/rules/task-pipeline.mdc +10 -5
- package/evals/RESULTS.md +1 -1
- package/evals/task-pipeline.evals.json +16 -0
- package/package.json +3 -3
- package/plugins/task-pipeline/.claude-plugin/plugin.json +1 -1
- package/plugins/task-pipeline/agents/verifier.md +4 -1
- package/plugins/task-pipeline/commands/task-pipeline.md +22 -8
- package/plugins/task-pipeline/hooks/memory-nudge.sh +45 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/evidence-docs/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/evidence-docs/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/SKILL.md +13 -11
- package/plugins/task-pipeline/skills/task-pipeline/graph.schema.json +89 -4
- package/plugins/task-pipeline/skills/task-pipeline/pipeline.schema.json +7 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/backlog.md +25 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/certification.md +45 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/continuity.md +95 -26
- package/plugins/task-pipeline/skills/task-pipeline/references/deploy-targets.md +30 -4
- package/plugins/task-pipeline/skills/task-pipeline/references/doctrine-map.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/gates.md +6 -3
- package/plugins/task-pipeline/skills/task-pipeline/references/grill.md +1 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/hooks.md +2 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/iteration.md +387 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/knowledge-sources.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/loop-guard.md +4 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/portability.md +1 -0
- package/plugins/task-pipeline/skills/task-pipeline/references/retrospective.md +26 -1
- package/plugins/task-pipeline/skills/task-pipeline/references/stages.md +35 -11
- package/plugins/task-pipeline/skills/task-pipeline/references/waves.md +80 -36
- package/plugins/task-pipeline/skills/task-pipeline/references/work-graph.md +54 -5
- package/plugins/task-pipeline/skills/task-pipeline/scripts/backlog_sources.py +212 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/context_packets.py +3 -3
- package/plugins/task-pipeline/skills/task-pipeline/scripts/graph.py +205 -22
- package/plugins/task-pipeline/skills/task-pipeline/scripts/lessons.py +1137 -0
- package/plugins/task-pipeline/skills/task-pipeline/scripts/stage_checkpoint.py +120 -15
- package/plugins/task-pipeline/skills/task-pipeline/scripts/waves.py +546 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/README.md +6 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/backlog-sources.schema.json +96 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/canvas.md +97 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/dossier.md +82 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/inventory.schema.json +94 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/lesson.schema.json +221 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/questions.md +45 -0
- package/plugins/task-pipeline/skills/task-pipeline/templates/retro.md +4 -0
- 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:]))
|