@christang/keel 5.20.0 → 5.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -9
- package/assets/bootstrap/AGENTS.md +1 -1
- package/assets/openspec/schemas/keel-spec-driven/templates/tasks.md +4 -1
- package/bin/keel.js +127 -22
- package/package.json +1 -1
- package/plugins/keel/.claude-plugin/plugin.json +1 -1
- package/plugins/keel/.codex-plugin/plugin.json +1 -1
- package/plugins/keel/skills/keel-align-expectations/SKILL.md +4 -18
- package/plugins/keel/skills/keel-review-checklist/SKILL.md +1 -1
- package/scripts/install_to_repo.py +45 -2
- package/scripts/validate_plugin.py +3120 -204
- package/src/core/config.js +192 -26
- package/src/core/context.js +9 -0
- package/src/core/gates.js +257 -40
- package/src/core/guard.js +77 -16
- package/src/core/task-contract.js +85 -3
package/README.md
CHANGED
|
@@ -198,21 +198,40 @@ The last thing a loop needs is permission to *start*. Declare which issues may b
|
|
|
198
198
|
being asked about:
|
|
199
199
|
|
|
200
200
|
```yaml
|
|
201
|
-
triage: #
|
|
202
|
-
|
|
201
|
+
triage: # either source admits; absent means nothing does
|
|
202
|
+
labels: # labels the issue carries
|
|
203
|
+
- auto
|
|
204
|
+
issues: # issue numbers, declared here rather than on the issue
|
|
205
|
+
- 62
|
|
203
206
|
```
|
|
204
207
|
|
|
208
|
+
A bare list directly under `triage:` still means labels, so a declaration written before the second
|
|
209
|
+
source existed keeps its exact meaning.
|
|
210
|
+
|
|
205
211
|
```bash
|
|
206
|
-
gh issue view 42 --json labels
|
|
212
|
+
gh issue view 42 --json labels,number \
|
|
213
|
+
--jq '"--labels \([.labels[].name]|join(",")) --issue \(.number)"' \
|
|
214
|
+
| xargs keel triage
|
|
207
215
|
```
|
|
208
216
|
|
|
209
217
|
**Keel never fetches the issue.** You pass what `gh` returned, and the evaluation stays local,
|
|
210
218
|
offline and deterministic — the same properties that make every other Keel answer worth trusting.
|
|
219
|
+
At least one of `--labels` / `--issue` is required; supplying neither is asking for a fetch. A
|
|
220
|
+
`triage:` block Keel cannot fully read admits nothing at all and names the entry that failed —
|
|
221
|
+
granting the readable half would grant the entries beside your typo.
|
|
222
|
+
|
|
223
|
+
**One issue is the unit, in both sources.** A person applies a label, or types a number, for one
|
|
224
|
+
specific issue, so the policy admits a class you curate one issue at a time — not a guess about
|
|
225
|
+
which issues look easy, which is exactly the judgement that should not be automated.
|
|
226
|
+
|
|
227
|
+
They differ in **where your decision is written down**, which is why both exist:
|
|
211
228
|
|
|
212
|
-
A **label**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
automation can label issues,
|
|
229
|
+
- A **label** records it on the issue. In a repository whose issues come from other people, that
|
|
230
|
+
means an operations switch sits in the vocabulary you asked reporters to classify with, visible
|
|
231
|
+
and editable by whoever filed it. Keel also cannot check that a human applied it; if your
|
|
232
|
+
automation can label issues, that source is wider than it looks.
|
|
233
|
+
- An **issue number** records it in your own file. The reporter never sees it, only a committer can
|
|
234
|
+
change it, it shows up in a diff, and you can revoke one entry without touching the rest.
|
|
216
235
|
|
|
217
236
|
**Admission answers "may this begin" and nothing after it.** Alignment still escalates every
|
|
218
237
|
material choice, every gate still runs, and the write guard still binds. In particular:
|
|
@@ -324,8 +343,8 @@ keel lenses list
|
|
|
324
343
|
keel lenses add <name> [--force]
|
|
325
344
|
|
|
326
345
|
# Unattended triage — may this issue start work without asking?
|
|
327
|
-
# Keel never fetches the issue; pass what gh returned.
|
|
328
|
-
keel triage --labels <l1,l2> [--json]
|
|
346
|
+
# Keel never fetches the issue; pass what gh returned. At least one of the two.
|
|
347
|
+
keel triage [--labels <l1,l2>] [--issue <n>] [--json]
|
|
329
348
|
|
|
330
349
|
# Install / maintenance
|
|
331
350
|
keel --init | --install | --check | --doctor | --uninstall [--target <t>] [--dry-run]
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
- [ ] 1.1 <!-- Task description -->
|
|
19
19
|
- Covers:
|
|
20
|
-
- <source expectation: spec
|
|
20
|
+
- <source expectation: spec authority as `capability / requirement` or `capability / requirement / scenario` — a scenario is named in the THIRD segment, never the second, hidden-knowledge assumption, domain lens requirement, or critical D<n>/F<n>/A<n>/Q<n>; a Q<n> that OPENS an entry is an open question and requires an authorized fallback, while one named inside an entry about a fact is a citation and does not>
|
|
21
21
|
- Touch:
|
|
22
22
|
- <path>
|
|
23
23
|
- Verify:
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
- Scope check: pending
|
|
51
51
|
- Findings: pending
|
|
52
52
|
- Blocker: none
|
|
53
|
+
- Reauthorizations: none
|
|
53
54
|
|
|
54
55
|
<!-- Exceptional boundaries are declared only when they differ from defaults:
|
|
55
56
|
- Mode: diagnose-only or repo-action (both with `Touch: none`), or plan-first.
|
|
@@ -87,6 +88,7 @@
|
|
|
87
88
|
- Scope check: pending
|
|
88
89
|
- Findings: pending
|
|
89
90
|
- Blocker: none
|
|
91
|
+
- Reauthorizations: none
|
|
90
92
|
|
|
91
93
|
## 3. <!-- Task Group Name -->
|
|
92
94
|
|
|
@@ -117,6 +119,7 @@
|
|
|
117
119
|
- Scope check: pending
|
|
118
120
|
- Findings: pending
|
|
119
121
|
- Blocker: none
|
|
122
|
+
- Reauthorizations: none
|
|
120
123
|
|
|
121
124
|
## Invalidates
|
|
122
125
|
|
package/bin/keel.js
CHANGED
|
@@ -97,6 +97,7 @@ Usage:
|
|
|
97
97
|
keel gate task-start|task-complete|change-close [repo] [--change name] [--task id] [--action sync|archive] [--base git-ref] [--no-guard] [--record] [--json]
|
|
98
98
|
keel guard start|status|clear [repo] [--change name] [--task id] [--force] [--json]
|
|
99
99
|
keel lenses list|add [name] [repo] [--force]
|
|
100
|
+
keel triage [repo] [--labels <l1,l2>] [--issue <n>] [--json]
|
|
100
101
|
keel openspec [args...]
|
|
101
102
|
keel --init [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update]
|
|
102
103
|
keel --install [repo] [--target claude|codex|opencode] [--dry-run] [--force-template-update] [--with-git-hooks]
|
|
@@ -134,6 +135,8 @@ Examples:
|
|
|
134
135
|
keel lenses list
|
|
135
136
|
keel lenses add web
|
|
136
137
|
keel lenses add web --force
|
|
138
|
+
keel triage --labels auto
|
|
139
|
+
keel triage --issue 42
|
|
137
140
|
keel --init
|
|
138
141
|
keel --install
|
|
139
142
|
keel --install --target codex
|
|
@@ -187,6 +190,7 @@ function parseArgs(argv) {
|
|
|
187
190
|
lensesSubcommand: null,
|
|
188
191
|
lensName: null,
|
|
189
192
|
labels: null,
|
|
193
|
+
issue: null,
|
|
190
194
|
openspecArgs: [],
|
|
191
195
|
force: false,
|
|
192
196
|
projectionEvent: null,
|
|
@@ -238,6 +242,10 @@ function parseArgs(argv) {
|
|
|
238
242
|
parsed.labels = argv[++index] || "";
|
|
239
243
|
continue;
|
|
240
244
|
}
|
|
245
|
+
if (arg === "--issue" && parsed.action === "triage") {
|
|
246
|
+
parsed.issue = argv[++index] || "";
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
241
249
|
if (arg === "openspec" && parsed.action === null && parsed.repo === null) {
|
|
242
250
|
parsed.action = "openspec";
|
|
243
251
|
parsed.openspecArgs = argv.slice(index + 1);
|
|
@@ -559,14 +567,25 @@ function parseArgs(argv) {
|
|
|
559
567
|
fail("lens subcommands apply only to keel lenses");
|
|
560
568
|
}
|
|
561
569
|
if (parsed.action === "triage") {
|
|
562
|
-
|
|
570
|
+
// At least one attribute, rather than labels specifically: a repository
|
|
571
|
+
// that admits by issue number alone should not have to pass an empty label
|
|
572
|
+
// list to be answered. The requirement stays because Keel never fetches the
|
|
573
|
+
// issue, so a caller supplying nothing is expecting a fetch.
|
|
574
|
+
if (parsed.labels === null && parsed.issue === null) {
|
|
563
575
|
fail(
|
|
564
|
-
"keel triage requires --labels; Keel never fetches the
|
|
565
|
-
+ "what `gh issue view --json labels` returned"
|
|
576
|
+
"keel triage requires --labels or --issue; Keel never fetches the "
|
|
577
|
+
+ "issue, so pass what `gh issue view --json labels,number` returned"
|
|
566
578
|
);
|
|
567
579
|
}
|
|
568
|
-
|
|
569
|
-
|
|
580
|
+
if (parsed.issue !== null && !/^[1-9]\d*$/.test(parsed.issue)) {
|
|
581
|
+
fail(
|
|
582
|
+
`--issue takes an issue number as a bare number, such as --issue 62; `
|
|
583
|
+
+ `got ${parsed.issue || "nothing"}`
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
} else {
|
|
587
|
+
if (parsed.labels !== null) fail("--labels applies only to keel triage");
|
|
588
|
+
if (parsed.issue !== null) fail("--issue applies only to keel triage");
|
|
570
589
|
}
|
|
571
590
|
if (parsed.noGuard && parsed.action !== "gate") {
|
|
572
591
|
fail("--no-guard applies only to keel gate task-start");
|
|
@@ -1188,6 +1207,70 @@ function mergeOpenSpecSurfaceOverlay(content, action) {
|
|
|
1188
1207
|
return `${content}${separator}${overlay}`;
|
|
1189
1208
|
}
|
|
1190
1209
|
|
|
1210
|
+
// The block plus the whitespace the merge above inserted in front of it, and
|
|
1211
|
+
// nothing else. Removing only the marked span leaves the separator behind, and
|
|
1212
|
+
// the file it is left in belongs to OpenSpec — so the match takes the newlines
|
|
1213
|
+
// immediately before the block and the one that closes it, and the replacement
|
|
1214
|
+
// puts a single newline back. A blank line anywhere else in the file is not
|
|
1215
|
+
// this code's business.
|
|
1216
|
+
const OPENSPEC_SURFACE_OVERLAY_REMOVE_RE =
|
|
1217
|
+
/\n*<!--\s*keel:openspec-surface-overlay(?:\s+[^>]*)?\s*-->[\s\S]*?<!--\s*keel:openspec-surface-overlay:end\s*-->[ \t]*\n?/;
|
|
1218
|
+
|
|
1219
|
+
function stripOpenSpecSurfaceOverlay(content) {
|
|
1220
|
+
let next = content;
|
|
1221
|
+
// A loop rather than one replace: the merge only ever writes one block, so a
|
|
1222
|
+
// file carrying two is already unexpected, and leaving one of them behind is
|
|
1223
|
+
// the outcome this is cheapest to rule out.
|
|
1224
|
+
while (OPENSPEC_SURFACE_OVERLAY_RE.test(next)) {
|
|
1225
|
+
next = next.replace(OPENSPEC_SURFACE_OVERLAY_REMOVE_RE, (match, offset) =>
|
|
1226
|
+
offset === 0 ? "" : "\n"
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
return next;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function removeOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
1233
|
+
const counts = {
|
|
1234
|
+
removed: 0,
|
|
1235
|
+
absent: 0,
|
|
1236
|
+
missing: 0,
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
for (const surface of openspecOverlaySurfacesForTarget(target, repo)) {
|
|
1240
|
+
// `keel --install` without `--init` never creates the OpenSpec surfaces,
|
|
1241
|
+
// so a repository can legitimately reach uninstall with none of them.
|
|
1242
|
+
if (!fs.existsSync(surface.path)) {
|
|
1243
|
+
counts.missing += 1;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
const content = fs.readFileSync(surface.path, "utf8");
|
|
1247
|
+
const next = stripOpenSpecSurfaceOverlay(content);
|
|
1248
|
+
if (next === content) {
|
|
1249
|
+
counts.absent += 1;
|
|
1250
|
+
continue;
|
|
1251
|
+
}
|
|
1252
|
+
counts.removed += 1;
|
|
1253
|
+
if (options.dryRun) {
|
|
1254
|
+
process.stdout.write(
|
|
1255
|
+
`keel: would remove OpenSpec ${surface.action} overlay from ${surface.path}\n`
|
|
1256
|
+
);
|
|
1257
|
+
continue;
|
|
1258
|
+
}
|
|
1259
|
+
fs.writeFileSync(surface.path, next, "utf8");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
// Always reported, including `removed=0`: an uninstall that found nothing to
|
|
1263
|
+
// clean and an uninstall that never looked leave the same tree behind, and
|
|
1264
|
+
// the second one is the defect this exists to close.
|
|
1265
|
+
process.stdout.write(
|
|
1266
|
+
`keel: ${options.dryRun ? "would remove " : ""}OpenSpec ${overlayActionLabel()} `
|
|
1267
|
+
+ `overlay removed=${counts.removed} absent=${counts.absent} `
|
|
1268
|
+
+ `missing=${counts.missing}\n`
|
|
1269
|
+
);
|
|
1270
|
+
|
|
1271
|
+
return { status: 0, ...counts };
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1191
1274
|
function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
1192
1275
|
const surfaces = openspecOverlaySurfacesForTarget(target, repo);
|
|
1193
1276
|
const counts = {
|
|
@@ -1223,7 +1306,7 @@ function refreshOpenSpecSurfaceOverlay(repo, target, options = {}) {
|
|
|
1223
1306
|
|
|
1224
1307
|
if (counts.refreshed > 0 || counts.current > 0) {
|
|
1225
1308
|
process.stdout.write(
|
|
1226
|
-
`keel: ${options.dryRun ? "would refresh " : ""}OpenSpec
|
|
1309
|
+
`keel: ${options.dryRun ? "would refresh " : ""}OpenSpec ${overlayActionLabel()} `
|
|
1227
1310
|
+ `overlay refreshed=${counts.refreshed} current=${counts.current} `
|
|
1228
1311
|
+ `missing=${counts.missing}\n`
|
|
1229
1312
|
);
|
|
@@ -1547,17 +1630,9 @@ function gitConfigHooksPath(repo) {
|
|
|
1547
1630
|
|
|
1548
1631
|
function printStandingAuthorizationSurface(repo) {
|
|
1549
1632
|
process.stdout.write("\nStanding authorization:\n");
|
|
1550
|
-
const { declared, unknown } = readStandingAuthorization(repo);
|
|
1633
|
+
const { declared, unknown, message } = readStandingAuthorization(repo);
|
|
1551
1634
|
if (unknown.length > 0) {
|
|
1552
|
-
printDoctorLine(
|
|
1553
|
-
"authorize",
|
|
1554
|
-
"failed",
|
|
1555
|
-
`keel/config.yaml declares unrecognized ${
|
|
1556
|
-
unknown.length === 1 ? "action" : "actions"
|
|
1557
|
-
}: ${unknown.join(", ")}; accepted names are `
|
|
1558
|
-
+ `${STANDING_AUTHORIZATION_ACTIONS.join(", ")}. The whole declaration `
|
|
1559
|
-
+ "authorizes nothing until it is corrected"
|
|
1560
|
-
);
|
|
1635
|
+
printDoctorLine("authorize", "failed", message);
|
|
1561
1636
|
return false;
|
|
1562
1637
|
}
|
|
1563
1638
|
printDoctorLine(
|
|
@@ -1578,12 +1653,31 @@ function printStandingAuthorizationSurface(repo) {
|
|
|
1578
1653
|
|
|
1579
1654
|
function printTriageSurface(repo) {
|
|
1580
1655
|
process.stdout.write("\nUnattended triage:\n");
|
|
1581
|
-
const { labels } = readTriagePolicy(repo);
|
|
1656
|
+
const { labels, issues, unreadable } = readTriagePolicy(repo);
|
|
1657
|
+
// An unreadable declaration is reported as broken rather than as absent. The
|
|
1658
|
+
// owner who typed it needs to know their file is the reason nothing runs,
|
|
1659
|
+
// which "undeclared" would tell them is not the case.
|
|
1660
|
+
if (unreadable.length > 0) {
|
|
1661
|
+
printDoctorLine(
|
|
1662
|
+
"triage",
|
|
1663
|
+
"unreadable",
|
|
1664
|
+
`keel/config.yaml declares triage entries Keel could not read — `
|
|
1665
|
+
+ `${unreadable.join(", ")}; no issue starts work unattended until the `
|
|
1666
|
+
+ "declaration is corrected"
|
|
1667
|
+
);
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
// Only the declared sources are named. Naming a source with nothing under it
|
|
1671
|
+
// would read as a policy that exists, on the one surface asked to answer
|
|
1672
|
+
// "what may start work here" in a single line.
|
|
1673
|
+
const declared = [];
|
|
1674
|
+
if (labels.length > 0) declared.push(`issues labelled ${labels.join(", ")}`);
|
|
1675
|
+
if (issues.length > 0) declared.push(`issues numbered ${issues.join(", ")}`);
|
|
1582
1676
|
printDoctorLine(
|
|
1583
1677
|
"triage",
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
?
|
|
1678
|
+
declared.length > 0 ? "ok" : "none",
|
|
1679
|
+
declared.length > 0
|
|
1680
|
+
? `${declared.join(", and ")} may start work unattended; `
|
|
1587
1681
|
+ "admission decides nothing after it, and no declaration authorizes a merge"
|
|
1588
1682
|
: "undeclared; no issue starts work unattended"
|
|
1589
1683
|
);
|
|
@@ -1876,7 +1970,7 @@ function runAction(options) {
|
|
|
1876
1970
|
.split(",")
|
|
1877
1971
|
.map((label) => label.trim())
|
|
1878
1972
|
.filter(Boolean);
|
|
1879
|
-
const verdict = triageIssue(repo, labels);
|
|
1973
|
+
const verdict = triageIssue(repo, labels, options.issue);
|
|
1880
1974
|
const payload = {
|
|
1881
1975
|
schemaVersion: 1,
|
|
1882
1976
|
command: "triage",
|
|
@@ -2004,7 +2098,18 @@ function runAction(options) {
|
|
|
2004
2098
|
}
|
|
2005
2099
|
|
|
2006
2100
|
if (options.action === "clear" || options.action === "uninstall") {
|
|
2007
|
-
|
|
2101
|
+
const uninstallStatus = runPython(
|
|
2102
|
+
INSTALL_SCRIPT,
|
|
2103
|
+
installerArgs(options, ["--uninstall"])
|
|
2104
|
+
);
|
|
2105
|
+
if (uninstallStatus !== 0) {
|
|
2106
|
+
return uninstallStatus;
|
|
2107
|
+
}
|
|
2108
|
+
return removeOpenSpecSurfaceOverlay(
|
|
2109
|
+
path.resolve(options.repo || process.cwd()),
|
|
2110
|
+
options.target,
|
|
2111
|
+
{ dryRun: options.dryRun }
|
|
2112
|
+
).status;
|
|
2008
2113
|
}
|
|
2009
2114
|
|
|
2010
2115
|
if (options.action === "check") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.39.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keel",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.39.0",
|
|
4
4
|
"description": "Keel OpenSpec execution discipline: stateless continuity, task capsules, deterministic gates, and expectation alignment for Codex and Claude Code.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "TanglmChris",
|
|
@@ -73,24 +73,10 @@ write guard are untouched by anything in the store.
|
|
|
73
73
|
|
|
74
74
|
## Unattended runs
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
work is a materiality decision, and a precedent may not move one out of that list.
|
|
81
|
-
|
|
82
|
-
Admission answers "may this begin" and decides nothing after it. Alignment still escalates material
|
|
83
|
-
choices, the gates still run, and the write guard still binds.
|
|
84
|
-
|
|
85
|
-
An unattended run may triage, author, implement, verify, push where `authorize:` permits, and
|
|
86
|
-
**open a pull request**. It **may not merge** one — merging is where an unreviewed decision becomes
|
|
87
|
-
the project's history, and no declaration in Keel authorizes it.
|
|
88
|
-
|
|
89
|
-
Stopping at a decision the user must make is the **designed boundary rather than a failure**.
|
|
90
|
-
Report where the run stopped and why. Do not widen the triage policy to stop it happening.
|
|
91
|
-
|
|
92
|
-
**Keel schedules nothing.** `/loop`, cron, and CI triggers belong to the host runtime; Keel's part
|
|
93
|
-
is making each step decidable with authority.
|
|
76
|
+
The admission rule, what admission does and does not authorize, the open-a-pull-request/no-merge
|
|
77
|
+
boundary, and that Keel schedules nothing are stated once, in this repository's `AGENTS.md`, under
|
|
78
|
+
its own `## Unattended runs` section. Read the boundary there — this skill states no separate copy,
|
|
79
|
+
so the two surfaces cannot drift against each other.
|
|
94
80
|
|
|
95
81
|
## Domain lenses
|
|
96
82
|
|
|
@@ -23,7 +23,7 @@ Read the selected OpenSpec proposal, design, specs, tasks, diff, and command evi
|
|
|
23
23
|
- For a red-green strategy (`vertical-tdd`, `regression-first`), confirm concrete per-label `.red` and `.green` Evidence exists for the same check; evidence-first tasks instead name their observable proof.
|
|
24
24
|
- A failure message must **name the actual cause** of what it reports. Watch for one condition guarding **two distinct failures** — `if result is None or result["status"] != expected` reports the first failure's message when the second one happened, sending the reader to a place with no problem in it. Split the condition. No gate can judge this: deciding whether a sentence misleads needs a model, so it stays here.
|
|
25
25
|
- When two tasks in the change declared the same Touch set under a red-green strategy — `keel gate task-start` warns about this — ask whether they turned out to be **one behavior** split in half. The tell is that the first task's minimal implementation was wrong in the field, or that the second had no honest red left because the first already made its checks pass. The gate can only see the shape; by completion you can see the outcome, which is the only point at which this is answerable.
|
|
26
|
-
- Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started
|
|
26
|
+
- Scope attribution is now the gate's job by default: `keel gate task-complete` compares the worktree against the dirty set `task-start` recorded and refuses a path outside Touch, so a `pass` is evidence about scope rather than silence about it. Two cases still need you. When the gate reports paths as *unattributed* — no recorded set, because the manifest predates the field or the guard was cleared — nothing was checked and the review is the only scope evidence. And a path already dirty when the task started stays unattributed only while its content matches what was recorded then; if the task changed it again, the gate attributes it — so read the gate's silence about such a path as evidence its content was never touched again, not as an unconditional exemption.
|
|
27
27
|
- For `Coupling: required`, confirm one complete candidate reached its completion gate and generated artifacts are aligned.
|
|
28
28
|
|
|
29
29
|
## Semantic Review
|
|
@@ -129,7 +129,20 @@ TASKS_COMMIT_STATUS_PATTERNS = (
|
|
|
129
129
|
# verbatim inside the lookahead, so the token that matches is the token that
|
|
130
130
|
# always matched, minus the all-decimal ones.
|
|
131
131
|
_HASH_SHAPED_TOKEN = r"\b(?=[0-9a-f]{7,40}\b)[0-9a-f]*[a-f][0-9a-f]*\b"
|
|
132
|
-
|
|
132
|
+
# A context word has to be a word. Matched anywhere on the line, `main` was
|
|
133
|
+
# supplied by `remaining`, `domain`, and `maintains`, and `head` by `heading`
|
|
134
|
+
# — and the tokens sitting beside those words in this repository's own
|
|
135
|
+
# history are `sha256:` contract anchors, not commit identifiers (#65).
|
|
136
|
+
# The inflections are spelled out rather than left to a prefix match: they are
|
|
137
|
+
# the words an author writes about the act this rule exists to catch, and the
|
|
138
|
+
# stricter list that drops `committed` and `hashes` would cost real refusals.
|
|
139
|
+
# The Chinese words carry no boundary because none is definable between two
|
|
140
|
+
# word characters — `\b提交\b` matches neither `已提交` nor `未提交`, which are
|
|
141
|
+
# exactly what the wording rule is for.
|
|
142
|
+
_HASH_CONTEXT_WORD = (
|
|
143
|
+
r"(?:\b(?:commits?|committed|committing|master|main|HEAD|hash(?:es)?)\b"
|
|
144
|
+
r"|提交|合入|哈希)"
|
|
145
|
+
)
|
|
133
146
|
TASKS_CONTEXTUAL_HASH_RE = re.compile(
|
|
134
147
|
rf"(?i){_HASH_CONTEXT_WORD}.*{_HASH_SHAPED_TOKEN}|"
|
|
135
148
|
rf"{_HASH_SHAPED_TOKEN}.*{_HASH_CONTEXT_WORD}"
|
|
@@ -662,6 +675,35 @@ def is_tasks_rule_line(line: str) -> bool:
|
|
|
662
675
|
)
|
|
663
676
|
|
|
664
677
|
|
|
678
|
+
# The task contract compiler's own field bound, from `parseTasks()` in
|
|
679
|
+
# `src/core/task-contract.js`: a field starts at a two-space `- Name:` line and
|
|
680
|
+
# holds every line until the next one. Keeping the two readers on one rule is
|
|
681
|
+
# what stops a line being a Covers entry for the compiler and prose for this
|
|
682
|
+
# check.
|
|
683
|
+
TASKS_FIELD_LABEL_RE = re.compile(r"^ {2}- ([A-Za-z][A-Za-z /-]+):")
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def covers_field_lines(content: str) -> set[int]:
|
|
687
|
+
"""1-based line numbers inside a `Covers` field.
|
|
688
|
+
|
|
689
|
+
A Covers entry is a citation — its segments must resolve to a requirement
|
|
690
|
+
or scenario that exists in a spec — so naming a requirement about dirty
|
|
691
|
+
worktrees or commit identifiers is not recording one. Read as prose, those
|
|
692
|
+
citations left an author no repair but to rename the requirement, which is
|
|
693
|
+
what 5.19.0 did and what #65 reports. Every citation is written on a line
|
|
694
|
+
below the label, so the exempt region is the field and not the label line.
|
|
695
|
+
"""
|
|
696
|
+
inside = False
|
|
697
|
+
lines: set[int] = set()
|
|
698
|
+
for number, line in enumerate(content.splitlines(), start=1):
|
|
699
|
+
label = TASKS_FIELD_LABEL_RE.match(line)
|
|
700
|
+
if label is not None:
|
|
701
|
+
inside = label.group(1) == "Covers"
|
|
702
|
+
if inside:
|
|
703
|
+
lines.add(number)
|
|
704
|
+
return lines
|
|
705
|
+
|
|
706
|
+
|
|
665
707
|
def check_tasks_semantics(repo: Path) -> list[str]:
|
|
666
708
|
changes_root = repo / OPENSPEC_ROOT / "changes"
|
|
667
709
|
if not changes_root.is_dir():
|
|
@@ -682,8 +724,9 @@ def check_tasks_semantics(repo: Path) -> list[str]:
|
|
|
682
724
|
line = line_number_for_offset(content, match.start())
|
|
683
725
|
errors.append(f"{relative}:{line}: {message}")
|
|
684
726
|
|
|
727
|
+
cited = covers_field_lines(content)
|
|
685
728
|
for line_number, line in enumerate(content.splitlines(), start=1):
|
|
686
|
-
if is_tasks_rule_line(line):
|
|
729
|
+
if is_tasks_rule_line(line) or line_number in cited:
|
|
687
730
|
continue
|
|
688
731
|
for pattern, message in TASKS_COMMIT_STATUS_PATTERNS:
|
|
689
732
|
if pattern.search(line):
|