pi-gauntlet 8.1.2 → 8.1.4
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 +8 -0
- package/README.md +4 -3
- package/extensions/lib/plan-check.test.ts +17 -1
- package/extensions/lib/plan-check.ts +3 -2
- package/extensions/lib/telemetry-collect.test.ts +12 -0
- package/extensions/phase-tracker.test.ts +3 -1
- package/extensions/plan-tracker.test.ts +273 -10
- package/extensions/plan-tracker.ts +80 -15
- package/package.json +1 -1
- package/skills/brainstorming/reference/amendment-surface.md +3 -3
- package/skills/brainstorming/reference/spec-finalization.md +14 -12
- package/skills/gauntlet-resume/SKILL.md +2 -2
- package/skills/gauntlet-resume/reference/brief-contract.md +4 -2
- package/skills/gauntlet-resume/reference/reconstruction.md +10 -6
- package/skills/loop-implementation/SKILL.md +30 -29
- package/skills/loop-implementation/reference/coordinated-legs.md +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v8.1.4 - 2026-11-01
|
|
4
|
+
|
|
5
|
+
- Ledger task labels compact criterion descriptions to at most 80 characters at a word boundary with `...` when shortened, preserving attempts prefixes, repository owners and short criteria.
|
|
6
|
+
|
|
7
|
+
## v8.1.3 - 2026-11-01
|
|
8
|
+
|
|
9
|
+
- The loop engine runs on one per-row budget: a fresh ledger carries `allowance: <maxIterationsPerRow>` (default 4) in place of `cap:`, a `4; allowance N` or `5; allowance N` approval reply overrides the setting, and each `retry <n> | <hint>` decision adds one attempt to that row. The driver drops funded rounds, `cap +N`, the total-iteration cap and amend-gated escalation; a row's last allowed attempt runs once on `escalationLoop.implModel` under option 4 and option 5 alike, and exit-verification failure gets one escalated repair per flow. Human stops offer only `retry <n> | <hint>` and `rows +N` and print each open row as `<attempts> of <allowance>`; the task tracker labels rows `<attempts>/<allowance>: <criterion text>` while task names stay `row N`. A legacy `cap:` ledger migrates on resume through a parent `loop: migrate allowance` commit. (#108)
|
|
10
|
+
|
|
3
11
|
## v8.1.2 - 2026-11-01
|
|
4
12
|
|
|
5
13
|
- `gatekeep-pr` selects the viewer's newest submitted, non-dismissed review and, when its commit is an ancestor of the assessed head, sends the reviewer its posted items and only the intervening code delta. The report counts addressed items; contract checks still judge the whole head. Assess pins its review to the assessed commit and checks the response; own-PR comment reviews supply the same bounded baseline.
|
package/README.md
CHANGED
|
@@ -39,7 +39,7 @@ Concretely, one change through the gauntlet:
|
|
|
39
39
|
1. Opt in with `/skill:brainstorming` and describe the change. The command is the reliable discovery-and-entry path; explicit prose requesting brainstorming/the gauntlet also supplies consent when the skill is already loaded or an available instruction or route identifies it. An ordinary implementation request or an agent recommendation is not consent. A leading run of GitHub references (`#N`, `owner/repo#N`, or a `/pull/N` or `/issues/N` URL), separated by whitespace or commas, resolves each reference as a PR first: related open, same-repository, non-fork PRs on one base form one rework subject, including drafts. Admission runs in listed order and relatedness is checked before provisioning; the first retained PR is home, whose existing branch rebases onto the fetched PR base by default, then member commits replay onto it in listed order. The first intent word `merge-forward` opts out and keeps the merge path. An unresolved conflict after its scoped helper aborts that step and continues to the spec with the repair as Task 1. A single non-PR reference follows the ticket path. The first non-reference token starts intent text; mid-sentence references and other ticket/free-text subjects keep their existing paths. The combined code, PR-carried specs, claims, and review threads are the brainstorm input, with each PR's provenance retained; only home's bound ticket supplies acceptance criteria, while member tickets supply context. A single PR retains the same rework path without member replays or a close offer. The combined state is judged against constraints from the fetched current base HEAD, not the fork point or the PRs' proposed policy edits; see the [PR-subject contract](skills/brainstorming/reference/pr-subject.md) and [PR-head worktree owner](skills/using-git-worktrees/reference/pr-head.md). **`brainstorming`** sets up an isolated worktree (on the existing branch for PR rework), explores the codebase, and turns the subject into a written spec - when its recon finds a cited case that the ask is the wrong change (misdiagnosed, wrong-sized, already solved, a band-aid, or cutting across a sound pattern - including a repo convention that is itself debt) and can name the alternative, question one is a framing question (`as framed`, `pivot: <alternative>`, or `smaller or none`); otherwise it opens with one sentence naming what it checked, and every approach it proposes ends with a `Pattern:` line naming what it follows or cuts across; when the run starts from a ticket, the spec carries the ticket's acceptance criteria verbatim, each with a disposition (`in-scope`, `deviates:`, `deferred:`, `venue:`, `elsewhere:` for a row another repo's part delivers), scope cuts come only from your answer to a cut question and are listed at the gate, the conformance gate checks the in-scope ones, and `/skill:check-delivery` verifies `venue:` rows before the prod deploy. A multi-model critique runs on it automatically. If the spec replaces a known prior spec, brainstorming marks the predecessor with a `> **Superseded by:**` banner under its title (default format, syntax overridable via `.pi/gauntlet-overrides.md`; candidates come from brainstorming's scout recon, never a mechanical sweep). **You read and approve the spec - human gate 1.** Options 1/2/4/5 approve and pick an engine; option 6 approves the content only: it writes `**Engine:** deferred` under `**Amend-grant:** none`, pushes the branch, opens a draft PR carrying just the spec, prints the URL, and ends the flow - no plan, no loop, no handoff brief. For a coordinated spec, it first pushes every companion branch to origin, then opens one home draft PR; it creates no companion PRs. A later `brainstorming #N` reconstructs the recorded companions from their origin branches before re-presenting the implementation gate; the [publication and re-gate owner](skills/brainstorming/reference/spec-pr.md) defines all-member preflight, recovery, and cleanup. The branch stays; a gauntlet-owned worktree is removed, a host-owned checkout is reported and left. Option 6 is withheld on a PR subject (`Withheld: draft PR - #N already exists`) and when the branch's diff against the default branch is not spec-only. A later `brainstorming #N` on that PR classifies it from the diff alone: one added spec carrying the marker plus banner-only predecessor edits re-presents the gate with 1/2/4/5 (3 only when the spec commit carries a council audit) and skips gather, questionary, council, and scout; a spec-only diff without the marker prints `spec-only PR without the gate marker - running lint and council` and takes the normal brainstorm path with the carried spec as the draft; any other diff takes the rework path. Approval at that re-gate writes `**Engine:**`, `**Amend-grant:**`, and `**PR:** <owner/repo#N>` as a new commit on the PR branch and chains into planning or the loop as usual. Before approval, PR rework's sole write/run exception outside the spec directories permits only the base sync (rebase or merge), each member replay or merge, their conflict helpers' edits to the conflicted files, and their scoped conflict tests; other subjects have no implementation code yet.
|
|
40
40
|
2. By default, **`writing-plans`** decomposes the approved spec into atomic, independently-verifiable tasks, grouped into parallel waves where they don't touch the same files. Experimental approval options 4/5 instead select **`loop-implementation`**: the plan phase is skipped and a stamped acceptance-criteria ledger replaces the task plan. Option 4 retains amendment review; option 5 grants auto-apply amends, with redraws still stopping. See [Loop engine operations](doc/configuration.md#loop-engine-operations).
|
|
41
41
|
3. **`subagent-driven-development`** executes the plan in waves, with task implementers followed by one integrated `wave-reviewer` after the scoped test gate passes. The reviewer checks anchored spec clauses and code quality together; repairs stay on the current tree and pass the same gate before re-review. TDD-locked: red, green, refactor. Independent implementation, review, and council batches remain parallel, but their dispatches are foreground: the orchestrator waits for terminal results before accepting work or advancing a phase.
|
|
42
|
-
The loop alternative to step 3 runs one fresh `loop-implementer` per seed or row iteration, without implement-phase reviewers.
|
|
42
|
+
The loop alternative to step 3 runs one fresh `loop-implementer` per seed or row iteration, without implement-phase reviewers. Each row starts with the shared allowance (default 4); approval replies such as `4; allowance 6` override it for one run. Under either loop approval option, the last allowed attempt escalates once per row on `escalationLoop.implModel` (the main-loop model when unset); one exit-verification repair per flow uses the same model. Exhaustion and stagnation stop for a human decision: `retry <n> | <hint>` adds one attempt to that row, while `rows +N` raises every row's allowance. Attempts never reset and there is no total iteration cap. Ledger refresh completes implement when every row is closed; verify step 1 runs the gate set: deduplicated (owning checkout, command) pairs from done rows' recorded checks, with ownership from the ledger. Gate output goes to logs; the driver keeps exit codes and paths, not test output. CI on push owns the full run.
|
|
43
43
|
|
|
44
44
|
4. **verify**: the parent first runs the plan's full verification command set for waves, or the ledger gate set for loop; only a passing result permits whole-diff code review, then the **conformance gate**. A review fix invalidates that result, so the parent reruns the same set before the next review or conformance gate. Loop conformance repairs and finish also use the gate set, never the ledger's full `verification:` command. When the overrides file declares a happy path for the touched paths (see [Project-specific overrides](#project-specific-overrides)), the parent runs it once here, between code review and the conformance gate, and the reviewer reads its transcript as runtime evidence - a hang or a failed request shows up in the audit with the transcript quoted - as a gap when an origin clause ties to it, otherwise as a `happy-path: failed - unattributable` line - never as a silent green. The conformance subagent reads the finished code and docs against your *original words* from step 1, not the plan, and reports per-requirement: delivered, partial, missing, drifted, or unauthorized. Unauthorized rows - shipped surface no human input asked for, whether it crept in or was laundered through the spec - follow their recommendation like every other row: a contained removal auto-runs, anything another requirement leans on is deferred with a plain-language "I'd cut it / I'd keep it" recommendation. Inside a brainstorming-entered flow this gate is machine-blocked from being skipped. Compatible executable recommendations auto-run through an isolated fix-and-re-audit loop with no prompt; anything still open surfaces as a numbered decision brief - one item per decision, the problem in plain words first, lettered options each with a one-line consequence, and `Recommended: <letter> - <reason>` or `No recommendation - pick one`. Reply with one line per item (`1. B`), or bare `1` to take every recommendation (no-recommendation items are re-asked by number); a current `CONFORMS` / no-concerns result goes straight to the branch options with no extra conformance sign-off.
|
|
45
45
|
5. **`finishing-a-development-branch`**: PR, draft PR, squash, keep, or discard; PR rework's row 1 offers a lease push of the rebased home branch to the existing PR (`Base:` records in `merge:` form push non-force). A spec whose header carries `**PR:**` (approved at a spec-PR re-gate) and no `## Rework record` replaces the two create rows with `Push to PR #N and mark it ready for review` and `Push to PR #N, keep it a draft` (`Push to PR #N (already ready for review)` when the PR is already ready): both push without force after strip-and-seal, the first then runs `gh pr ready`, neither rewrites the PR body, and both are withheld when origin's branch head is no longer an ancestor of local HEAD. For coordinated specs, these [header-linked rows 1/2](skills/finishing-a-development-branch/reference/spec-pr.md#push-rows-12) deliver companions in recorded order before one home seal carrying every companion PR URL, then push home and apply its readiness choice. Row 1 makes companion drafts ready too; row 2 creates drafts but never converts an already-ready PR back to draft. Companion URLs appear in the shipped summary, companions first and home last, not in the unchanged home PR body; keep that summary for cross-repo review because successful home delivery removes receipts. A spec still carrying `**Engine:** deferred` stops finishing (and `gauntlet-resume`) with `spec approved for handoff only - run brainstorming #N` before any phase change, verification, or push. The PR-rework push appends a `## Rework` block naming absorbed PRs while retaining the original description bytes; the header-linked `**PR:**` push leaves the body untouched. Only after a successful rework push and home-description edit does one gated confirmation show the exact close comments and eligible member targets, linking home; declining or any other finish option leaves members open. Closing never deletes member branches, and finish posts no review-thread replies. Its `Before:` handle includes the full portable pre-rework SHA and the local `refs/gauntlet/pre-rework/pr-<N>` ref in the rework checkout. The [finishing owner](skills/finishing-a-development-branch/SKILL.md#pr-subject-row-1-push-to-the-pr-branch) defines the landing and body-update contract. Once a PR exists, run `/skill:gatekeep-pr <pr>` to verify it before merging - it judges the diff against the spec at the PR's head (the acceptance-criteria dispositions and Design you approved; the PR description when no spec applies) - rows the spec defers or deviates are settled, every other row needs its mechanism (code, tests, docs) - and against the rubric; whatever can be seen only after merge is left to step 6. **Human gate 2** - the only other decision you make.
|
|
@@ -600,7 +600,7 @@ Anything a wrapper skill contains beyond trigger phrases is misplaced - move it
|
|
|
600
600
|
|
|
601
601
|
Settled skill-owned turns carry a machine-readable first line identifying a gate, question, or terminal outcome. Programs answering these turns use the [gate inventory and reply contract](doc/gate-contract.md); the rendered row bodies define valid replies.
|
|
602
602
|
|
|
603
|
-
Choose experimental loop options 4/5, configure the
|
|
603
|
+
Choose experimental loop options 4/5, configure the per-row allowance and worker model, and operate the planless ledger through [Loop engine operations](doc/configuration.md#loop-engine-operations). The menu shows the resolved allowance; `; allowance N` overrides it for one run. Resume preserves an existing allowance or migrates a legacy cap after pending recovery. Waves remains the default.
|
|
604
604
|
|
|
605
605
|
| Want to ... | Read |
|
|
606
606
|
|---|---|
|
|
@@ -615,7 +615,8 @@ The conformance gate's model, the spec council's roster, and the phase-tracker's
|
|
|
615
615
|
| `closureReview.enforce` | phase-tracker | `true` | conformance gate on/off - [detail](./doc/configuration.md#conformance-gate-model) |
|
|
616
616
|
| `closureReview.model` | phase-tracker | unset (inherits) | conformance reviewer model - [detail](./doc/configuration.md#conformance-gate-model) |
|
|
617
617
|
| `closureReview.maxFixRounds` | phase-tracker | `5` | fix rounds before escalation - [detail](./doc/configuration.md#conformance-gate-model) |
|
|
618
|
-
| `escalationLoop.implModel` | phase-tracker | unset (main loop) |
|
|
618
|
+
| `escalationLoop.implModel` | phase-tracker | unset (main loop) | execution escalation under either loop option; review escalation remains separate - [detail](./doc/configuration.md#escalation-loop) |
|
|
619
|
+
| `loopEngine.maxIterationsPerRow` | phase-tracker | `4` (floor `1`) | fresh-ledger per-row allowance; run override on approval 4/5 - [detail](./doc/configuration.md#setting-and-command-resolution) |
|
|
619
620
|
| `phaseModels.plan` / `.implement` / `.verify` / `.ship` | phase-tracker | unset (no switch) | `provider/id[:level]` applied persistently at that phase's start - [detail](./doc/configuration.md#phase-models) |
|
|
620
621
|
| `flowGuards.enforce` | phase-tracker | `true` | phase gates on/off - [detail](./doc/configuration.md#phase-tracker) |
|
|
621
622
|
| `flowGuards.specDirs` | phase-tracker, plan-tracker; telemetry and spec-index consumers | `["doc/specs", "docs/specs"]` | where specs and sibling plans live - [detail](./doc/configuration.md#phase-tracker) |
|
|
@@ -16,7 +16,7 @@ const ledger = (rows = ["1 | open | 0 |", "2 | cut | 0 | deferred: #70", "3 | do
|
|
|
16
16
|
"# Ledger - loop engine\n**Spec:** `doc/specs/test.md`\nbase: abc\nverification: check\niterations: 0\n\n| row | status | attempts | evidence |\n|---|---|---|---|\n" + rows.map(r => `| ${r} |`).join("\n") + "\n\n## Decisions\n";
|
|
17
17
|
|
|
18
18
|
test("ledger admission bypasses task checks and permits empty open evidence", () => {
|
|
19
|
-
for (const input of [ledger(), ledger().replace("iterations: 0\n",
|
|
19
|
+
for (const input of [ledger(), ...["cap: 97", "allowance: 4"].map(header => ledger().replace("iterations: 0\n", `iterations: 0\n${header}\n`))]) {
|
|
20
20
|
assert.deepEqual(checkPlan(input, ledgerSpec, { exists: () => false, glob: () => [] }).findings, []);
|
|
21
21
|
}
|
|
22
22
|
});
|
|
@@ -1271,6 +1271,22 @@ const emptyPort: FsPort = { exists: () => false, glob: () => [] };
|
|
|
1271
1271
|
test("coordinated ledger expands shared ownership and defaults unlisted ACs home", () => {
|
|
1272
1272
|
assert.deepEqual(checkPlan(coordinatedLedger(), coordinatedSpec, emptyPort).findings, []);
|
|
1273
1273
|
});
|
|
1274
|
+
test("coordinated ledger budget header admits allowance or legacy cap, never both", () => {
|
|
1275
|
+
for (const key of ["allowance", "cap"]) {
|
|
1276
|
+
for (const value of ["1", "4", "9007199254740991"]) {
|
|
1277
|
+
const input = coordinatedLedger().replace("cap: 20", `${key}: ${value}`);
|
|
1278
|
+
assert.deepEqual(checkPlan(input, coordinatedSpec, emptyPort).findings, [], `${key}: ${value}`);
|
|
1279
|
+
}
|
|
1280
|
+
for (const value of ["", "0", "-1", "1.5", "04", "four", "9007199254740992"]) {
|
|
1281
|
+
const input = coordinatedLedger().replace("cap: 20", `${key}: ${value}`);
|
|
1282
|
+
assert.ok(checkPlan(input, coordinatedSpec, emptyPort).findings.some(f => f.check === "ledger"), `${key}: ${value}`);
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
for (const header of ["", "allowance: 4\nallowance: 4", "cap: 20\ncap: 20", "allowance: 4\ncap: 20"]) {
|
|
1286
|
+
const input = coordinatedLedger().replace("cap: 20", header);
|
|
1287
|
+
assert.ok(checkPlan(input, coordinatedSpec, emptyPort).findings.some(f => f.check === "ledger"), header);
|
|
1288
|
+
}
|
|
1289
|
+
});
|
|
1274
1290
|
for (const scope of ["", "scope: AC 1\nscope: AC 2", "scope: AC 1, AC 1", "scope: AC 0", "scope: AC -1", "scope: AC 1.5", "scope: AC 4", "scope: 1", "scope: AC 1-2", "scope: AC 1,", "scope: none, AC 1"]) {
|
|
1275
1291
|
test(`coordinated scope rejects ${JSON.stringify(scope)}`, () => {
|
|
1276
1292
|
assert.ok(checkPlan(coordinatedLedger(), coordinatedSpec.replace("scope: AC 1, AC 2", scope), emptyPort).findings.some(f => f.check === "leg-scope"));
|
|
@@ -1205,9 +1205,10 @@ function coordinatedMetadata(lines: string[], legs: ReturnType<typeof parseLegs>
|
|
|
1205
1205
|
const sha = /^[a-f0-9]{7,40}$/;
|
|
1206
1206
|
const base = one("base", sha);
|
|
1207
1207
|
const iterations = one("iterations", /^(?:0|[1-9]\d*)$/);
|
|
1208
|
-
const
|
|
1208
|
+
const budgets = [...header("allowance"), ...header("cap")];
|
|
1209
|
+
if (budgets.length !== 1) add(budgets[0]?.i ?? 0, "expected exactly one allowance or cap header");
|
|
1210
|
+
else if (!/^[1-9]\d*$/.test(budgets[0].value) || !Number.isSafeInteger(Number(budgets[0].value))) add(budgets[0].i, "allowance or cap must be a positive safe integer");
|
|
1209
1211
|
if (!Number.isSafeInteger(Number(iterations))) add(0, "iterations must be a safe integer");
|
|
1210
|
-
if (!Number.isSafeInteger(Number(cap))) add(0, "cap must be a safe integer");
|
|
1211
1212
|
for (const [i, l] of lines.entries()) {
|
|
1212
1213
|
const m = /^(base|seeded) ([^:]+):/.exec(l);
|
|
1213
1214
|
if (m && !legs.some(leg => leg.repo === m[2].toLowerCase())) add(i, "foreign member header");
|
|
@@ -46,6 +46,18 @@ test("reopens match distinct string names, not position", () => {
|
|
|
46
46
|
assert.equal(countReopens([{ status: "complete" }], [{ status: "in_progress" }]), 0);
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
test("allowance label changes between ledger refreshes never change reopen identity", () => {
|
|
50
|
+
const before = [
|
|
51
|
+
{ name: "row 3", status: "complete", label: "2/5" },
|
|
52
|
+
{ name: "row 4 (repo-b)", status: "complete", label: "1/4" },
|
|
53
|
+
{ name: "G1: check", status: "complete" },
|
|
54
|
+
];
|
|
55
|
+
const relabeled = before.map(task => task.label ? { ...task, label: "2/6" } : task);
|
|
56
|
+
assert.equal(countReopens(before, relabeled), 0);
|
|
57
|
+
assert.equal(countReopens(before, relabeled.map(task => ({ ...task, status: "in_progress" }))), 3);
|
|
58
|
+
assert.equal(countReopens(before, relabeled.map(task => ({ ...task, status: "pending" }))), 0);
|
|
59
|
+
});
|
|
60
|
+
|
|
49
61
|
test("insertedText joins edit newText values or returns write content", () => {
|
|
50
62
|
assert.equal(insertedText({ path: "x", edits: [{ oldText: "a", newText: "b" }, { oldText: "c", newText: "d" }] }), "b\nd");
|
|
51
63
|
assert.equal(insertedText({ path: "x", content: "body" }), "body");
|
|
@@ -536,7 +536,9 @@ test("ledger refresh completes only started implement and keeps failed, in-fligh
|
|
|
536
536
|
for (const allCut of [true, false]) {
|
|
537
537
|
const dir = tempCwd({ piGauntlet: { flowGuards: { enforce: false } } });
|
|
538
538
|
const path = join(dir, "ledger.md");
|
|
539
|
-
|
|
539
|
+
execFileSync("git", ["init", "-q", dir]);
|
|
540
|
+
writeFileSync(join(dir, "spec.md"), "# Spec\n\n## Acceptance criteria\n- [ ] The tracker shows a readable step\n in-scope\n");
|
|
541
|
+
const save = (status: string, pending = "") => writeFileSync(path, `# Ledger - loop engine\n**Spec:** \`spec.md\`\n${pending}\n| row | status | attempts | evidence |\n|---|---|---|---|\n| 1 | ${status} | 0 | check |\n\n## Decisions\n`);
|
|
540
542
|
save(allCut ? "cut" : "failed");
|
|
541
543
|
const branch = implementBranch().slice(0, -1);
|
|
542
544
|
const h = harness({ cwd: dir, branch });
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
|
-
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { join } from "node:path";
|
|
6
7
|
import registerPlanTracker, { validateSnapshot } from "./plan-tracker.ts";
|
|
7
8
|
|
|
8
9
|
type ToolResult = {
|
|
9
10
|
content: { type: string; text: string }[];
|
|
10
|
-
details: { action: string; tasks: { name: string; status: string }[]; error?: string };
|
|
11
|
+
details: { action: string; tasks: { name: string; status: string; label?: string }[]; error?: string };
|
|
11
12
|
};
|
|
12
13
|
|
|
13
14
|
function harness(branch: unknown[] = [], cwd = process.cwd()) {
|
|
@@ -384,14 +385,25 @@ test("legacy-invalid snapshot: first update is rejected listing legacy offenders
|
|
|
384
385
|
assert.deepEqual(repaired.details.tasks.map((t) => t.status), ["complete", "skipped", "complete"]);
|
|
385
386
|
});
|
|
386
387
|
|
|
388
|
+
const descriptions = [
|
|
389
|
+
"Keep cut requirements visible", "Reject malformed allowance replies", "Verify loop behavior and recovery",
|
|
390
|
+
"Report failed checks", "Preserve completed work", "Start the next criterion",
|
|
391
|
+
];
|
|
392
|
+
const spec = (criteria = descriptions) => `# Spec\n\n## Acceptance criteria\n${criteria.map(text => `- [ ] ${text}`).join("\n")}\n`;
|
|
393
|
+
function ledgerFixture(prefix: string) {
|
|
394
|
+
const dir = mkdtempSync(join(tmpdir(), prefix));
|
|
395
|
+
execFileSync("git", ["init", "-q", dir]);
|
|
396
|
+
writeFileSync(join(dir, "spec.md"), spec(descriptions.map(text => `${text}\n in-scope`)));
|
|
397
|
+
return dir;
|
|
398
|
+
}
|
|
387
399
|
const ledger = (rows: string[], tail = "", pending = "") => [
|
|
388
|
-
"# Ledger - loop engine", pending,
|
|
400
|
+
"# Ledger - loop engine", "**Spec:** `spec.md`", pending,
|
|
389
401
|
"| row | status | attempts | evidence |", "|---|---|---|---|",
|
|
390
402
|
...rows.map(row => `| ${row} |`), "", "## Decisions", tail,
|
|
391
403
|
].join("\n");
|
|
392
404
|
|
|
393
405
|
test("refresh projects ledger states, numeric pending targets and live leg mappings", async () => {
|
|
394
|
-
const dir =
|
|
406
|
+
const dir = ledgerFixture("plan-refresh-");
|
|
395
407
|
const path = join(dir, "ledger.md");
|
|
396
408
|
const cases = [
|
|
397
409
|
{
|
|
@@ -425,7 +437,7 @@ test("refresh projects ledger states, numeric pending targets and live leg mappi
|
|
|
425
437
|
});
|
|
426
438
|
|
|
427
439
|
test("refresh replaces only whole ledger names, preserves Gn statuses and replays its snapshot", async () => {
|
|
428
|
-
const dir =
|
|
440
|
+
const dir = ledgerFixture("plan-refresh-kept-");
|
|
429
441
|
const path = join(dir, "ledger.md");
|
|
430
442
|
writeFileSync(path, ledger(["1 | open | 0 |", "2 | open | 0 |", "3 | done | 1 | passed"], "row 3 done | passed | row 1"));
|
|
431
443
|
const h = harness();
|
|
@@ -437,9 +449,9 @@ test("refresh replaces only whole ledger names, preserves Gn statuses and replay
|
|
|
437
449
|
const result = await h.call({ action: "refresh", path });
|
|
438
450
|
assert.equal(result.details.error, undefined);
|
|
439
451
|
assert.deepEqual(result.details.tasks, [
|
|
440
|
-
{ name: "row 3", status: "complete" },
|
|
452
|
+
{ name: "row 3", status: "complete", label: descriptions[2] },
|
|
441
453
|
{ name: "G1: closed", status: "complete" }, { name: "G2: working", status: "in_progress" },
|
|
442
|
-
{ name: "row 1", status: "pending" }, { name: "row 2", status: "pending" },
|
|
454
|
+
{ name: "row 1", status: "pending", label: descriptions[0] }, { name: "row 2", status: "pending", label: descriptions[1] },
|
|
443
455
|
{ name: "row 4 notes", status: "pending" }, { name: "G3: pending", status: "pending" },
|
|
444
456
|
]);
|
|
445
457
|
assert.deepEqual(validateSnapshot(result.details.tasks as any), []);
|
|
@@ -449,8 +461,258 @@ test("refresh replaces only whole ledger names, preserves Gn statuses and replay
|
|
|
449
461
|
assert.equal(replay.widget(), h.widget());
|
|
450
462
|
});
|
|
451
463
|
|
|
464
|
+
test("allowance labels count retry decisions, preserve identity and render on every status surface", async () => {
|
|
465
|
+
const dir = ledgerFixture("plan-allowance-");
|
|
466
|
+
const path = join(dir, "ledger.md");
|
|
467
|
+
const text = ledger([
|
|
468
|
+
"1 | cut | 3 | deferred: #1", "2 | done | 0 | baseline", "3 | open | 2 | check",
|
|
469
|
+
"4 | failed | 7 | failed", "5 | done | 1 | passed", "6 | open | 0 | check",
|
|
470
|
+
], [
|
|
471
|
+
"row 3 retry | hint | allowed attempts 5", "row 3 retry | next | allowed attempts 99",
|
|
472
|
+
"row 3 escalated | implModel round | allowed attempts 100", "cap +20 | historical | cap 40",
|
|
473
|
+
"row 4 retry | hint | allowed attempts 8", "row 5 done | passed | row 3",
|
|
474
|
+
"## Notes", "row 3 retry | not a decision | allowed attempts 101",
|
|
475
|
+
"## Leg mapping", "| row | ac | leg |", "|---|---|---|",
|
|
476
|
+
"| 1 | 1 | repo-a |", "| 2 | 2 | repo-a |", "| 3 | 3 | repo-b |",
|
|
477
|
+
"| 4 | 4 | repo-a |", "| 5 | 5 | repo-b |", "| 6 | 6 | repo-a |",
|
|
478
|
+
].join("\n"), "allowance: 4\npending: 3 | repo-b | abc1234");
|
|
479
|
+
writeFileSync(path, text);
|
|
480
|
+
const h = harness();
|
|
481
|
+
await h.call({ action: "init", tasks: [{ name: "G1: fixed", status: "complete" }, "2/5: G2: pending"] });
|
|
482
|
+
const result = await h.call({ action: "refresh", path });
|
|
483
|
+
assert.equal(result.details.error, undefined);
|
|
484
|
+
assert.deepEqual(result.details.tasks, [
|
|
485
|
+
{ name: "row 1 (repo-a)", status: "skipped", label: `${descriptions[0]} (repo-a)` },
|
|
486
|
+
{ name: "row 2 (repo-a)", status: "skipped", label: `${descriptions[1]} (repo-a)` },
|
|
487
|
+
{ name: "row 3 (repo-b)", status: "in_progress", label: `2/6: ${descriptions[2]} (repo-b)` },
|
|
488
|
+
{ name: "row 4 (repo-a)", status: "failed", label: `7/5: ${descriptions[3]} (repo-a)` },
|
|
489
|
+
{ name: "row 5 (repo-b)", status: "complete", label: `1/4: ${descriptions[4]} (repo-b)` },
|
|
490
|
+
{ name: "G1: fixed", status: "complete" },
|
|
491
|
+
{ name: "row 6 (repo-a)", status: "pending", label: `0/4: ${descriptions[5]} (repo-a)` },
|
|
492
|
+
{ name: "2/5: G2: pending", status: "pending" },
|
|
493
|
+
]);
|
|
494
|
+
assert.match(h.widget()!, /2\/6: Verify loop behavior and recovery \(repo-b\)$/);
|
|
495
|
+
assert.doesNotMatch(h.widget()!, /row 3/);
|
|
496
|
+
assert.match(result.content[0].text, /\[2\] row 3 \(repo-b\) \| 2\/6: Verify loop behavior and recovery \(repo-b\)/);
|
|
497
|
+
const status = await h.call({ action: "status" });
|
|
498
|
+
assert.match(status.content[0].text, /\[4\] row 5 \(repo-b\) \| 1\/4: Preserve completed work \(repo-b\)/);
|
|
499
|
+
const rendered = h.tool().renderResult(status, {}, h.theme).text!;
|
|
500
|
+
assert.match(rendered, /row 3 \(repo-b\) \| 2\/6: Verify loop behavior and recovery \(repo-b\)/);
|
|
501
|
+
assert.match(rendered, /row 4 \(repo-a\) \| 7\/5: Report failed checks \(repo-a\)/);
|
|
502
|
+
const replay = harness([{ type: "message", message: { role: "toolResult", toolName: "plan_tracker", details: status.details } }]);
|
|
503
|
+
await replay.fire("session_start");
|
|
504
|
+
assert.equal(replay.widget(), h.widget());
|
|
505
|
+
assert.deepEqual((await replay.call({ action: "status" })).details.tasks, status.details.tasks);
|
|
506
|
+
|
|
507
|
+
writeFileSync(path, text.replace("allowance: 4", "allowance: 7"));
|
|
508
|
+
const raised = await h.call({ action: "refresh", path });
|
|
509
|
+
assert.deepEqual(raised.details.tasks.map(t => [t.name, t.status]), result.details.tasks.map(t => [t.name, t.status]));
|
|
510
|
+
assert.equal(raised.details.tasks.find(t => t.name === "row 3 (repo-b)")!.label, `2/9: ${descriptions[2]} (repo-b)`);
|
|
511
|
+
assert.equal(raised.details.tasks.find(t => t.name === "row 5 (repo-b)")!.label, `1/7: ${descriptions[4]} (repo-b)`);
|
|
512
|
+
assert.equal(raised.details.tasks.length, result.details.tasks.length);
|
|
513
|
+
writeFileSync(path, text.replace("allowance: 4", "cap: 49"));
|
|
514
|
+
const legacy = await h.call({ action: "refresh", path });
|
|
515
|
+
assert.equal(legacy.details.error, undefined);
|
|
516
|
+
assert.deepEqual(legacy.details.tasks.filter(t => t.name.startsWith("row ")).map(t => t.label),
|
|
517
|
+
descriptions.map((description, i) => `${description} (${[2, 4].includes(i) ? "repo-b" : "repo-a"})`));
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
test("refresh compacts long criteria while preserving budgets, owners and short criteria", async () => {
|
|
521
|
+
const dir = ledgerFixture("plan-compact-labels-");
|
|
522
|
+
const path = join(dir, "ledger.md");
|
|
523
|
+
const compact = "Verify that refreshed ledger labels keep complete words within the";
|
|
524
|
+
const long = `${compact} description budget while retaining attempts and repository ownership`;
|
|
525
|
+
writeFileSync(join(dir, "spec.md"), spec([long, descriptions[1]]));
|
|
526
|
+
for (const mapped of [false, true]) {
|
|
527
|
+
const owner = mapped ? " (repo-a)" : "";
|
|
528
|
+
writeFileSync(path, ledger(["1 | open | 2 | check", "2 | open | 0 |"], mapped
|
|
529
|
+
? "## Leg mapping\n| row | ac | leg |\n|---|---|---|\n| 1 | 1 | repo-a |\n| 2 | 2 | repo-a |" : "", "allowance: 4"));
|
|
530
|
+
const h = harness([], dir);
|
|
531
|
+
const result = await h.call({ action: "refresh", path });
|
|
532
|
+
assert.equal(result.details.error, undefined);
|
|
533
|
+
assert.deepEqual(result.details.tasks, [
|
|
534
|
+
{ name: `row 1${owner}`, status: "pending", label: `2/4: ${compact}...${owner}` },
|
|
535
|
+
{ name: `row 2${owner}`, status: "pending", label: `0/4: ${descriptions[1]}${owner}` },
|
|
536
|
+
]);
|
|
537
|
+
assert.ok(`${compact}...`.length <= 80);
|
|
538
|
+
assert.ok(h.widget()!.endsWith(`2/4: ${compact}...${owner}`));
|
|
539
|
+
assert.ok(result.content[0].text.includes(`row 1${owner} | 2/4: ${compact}...${owner}`));
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
test("allowance labels tolerate surrounding whitespace in the header value", async () => {
|
|
544
|
+
const dir = ledgerFixture("plan-allowance-whitespace-");
|
|
545
|
+
const path = join(dir, "ledger.md");
|
|
546
|
+
const h = harness();
|
|
547
|
+
const labels = [];
|
|
548
|
+
for (const header of ["allowance: 4", "allowance: 4", "allowance: 4 "]) {
|
|
549
|
+
writeFileSync(path, ledger(["1 | open | 0 |"], "", header));
|
|
550
|
+
const result = await h.call({ action: "refresh", path });
|
|
551
|
+
assert.equal(result.details.error, undefined, header);
|
|
552
|
+
labels.push(result.details.tasks[0].label);
|
|
553
|
+
}
|
|
554
|
+
assert.deepEqual(labels, Array(3).fill(`0/4: ${descriptions[0]}`));
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("single-repo labels are projection-only and cannot enter through init or add", async () => {
|
|
558
|
+
const dir = ledgerFixture("plan-label-input-");
|
|
559
|
+
const path = join(dir, "ledger.md");
|
|
560
|
+
writeFileSync(path, ledger(["3 | open | 2 | check"], "row 3 retry | hint | allowed attempts 99", "allowance: 4"));
|
|
561
|
+
const h = harness();
|
|
562
|
+
const projected = await h.call({ action: "refresh", path });
|
|
563
|
+
assert.deepEqual(projected.details.tasks, [{ name: "row 3", status: "pending", label: `2/5: ${descriptions[2]}` }]);
|
|
564
|
+
assert.match(h.widget()!, /2\/5: Verify loop behavior and recovery$/);
|
|
565
|
+
const init = await h.call({ action: "init", tasks: [{ name: "row 3", status: "in_progress", label: "9/9" }] });
|
|
566
|
+
assert.deepEqual(init.details.tasks, [{ name: "row 3", status: "in_progress" }]);
|
|
567
|
+
const added = await h.call({ action: "add", tasks: [{ name: "G1: check", status: "pending", label: "9/9" }] });
|
|
568
|
+
assert.ok(added.details.tasks.every(t => !Object.hasOwn(t, "label")));
|
|
569
|
+
const objectSchema = h.parameters().args[0].tasks.args[0].args[0].args[0][1];
|
|
570
|
+
assert.deepEqual(Object.keys(objectSchema.args[0]), ["name", "status"]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
test("refresh resolves the owning root, normalizes original AC text, and keeps labels stable across status changes", async () => {
|
|
574
|
+
const dir = ledgerFixture("plan-descriptions-");
|
|
575
|
+
const nested = join(dir, "doc", "plans");
|
|
576
|
+
mkdirSync(nested, { recursive: true });
|
|
577
|
+
const path = join(nested, "ledger.md");
|
|
578
|
+
writeFileSync(join(dir, "spec.md"), [
|
|
579
|
+
"# Spec", "```md", "## Acceptance criteria", "- [ ] Not a criterion", "```",
|
|
580
|
+
"## Acceptance Criteria", "- [x] Keep cut requirements", " visible",
|
|
581
|
+
" deferred: #1", " disposition continuation", "- [ ] Reject malformed allowance replies",
|
|
582
|
+
" elsewhere: repo-b#2", "- [ ] Verify loop behavior", " and recovery | row 99 is only text",
|
|
583
|
+
" venue: staging - observe completion", "## Design", "- [ ] Not an acceptance criterion",
|
|
584
|
+
].join("\n"));
|
|
585
|
+
const text = ledger(["1 | cut | 0 | deferred", "2 | done | 0 | baseline", "3 | open | 2 | check"], "", "allowance: 4");
|
|
586
|
+
writeFileSync(path, text);
|
|
587
|
+
const h = harness([], nested);
|
|
588
|
+
const first = await h.call({ action: "refresh", path: "ledger.md" });
|
|
589
|
+
assert.equal(first.details.error, undefined);
|
|
590
|
+
assert.deepEqual(first.details.tasks.map(t => t.label), [
|
|
591
|
+
"Keep cut requirements visible", descriptions[1], "2/4: Verify loop behavior and recovery | row 99 is only text",
|
|
592
|
+
]);
|
|
593
|
+
assert.match(first.content[0].text, /row 3 \| 2\/4: Verify loop behavior and recovery \| row 99 is only text/);
|
|
594
|
+
for (const [status, tail, pending] of [
|
|
595
|
+
["failed", "", ""], ["failed", "", "pending: 3 | home | abc1234"],
|
|
596
|
+
["done", "row 3 done | passed | none", ""],
|
|
597
|
+
]) {
|
|
598
|
+
writeFileSync(path, text.replace("| 3 | open", `| 3 | ${status}`).replace("allowance: 4", `allowance: 4\n${pending}`) + tail);
|
|
599
|
+
const next = await h.call({ action: "refresh", path });
|
|
600
|
+
assert.equal(next.details.error, undefined);
|
|
601
|
+
assert.deepEqual(next.details.tasks.map(t => [t.name, t.label]), first.details.tasks.map(t => [t.name, t.label]));
|
|
602
|
+
}
|
|
603
|
+
writeFileSync(join(dir, "spec.md"), spec([descriptions[0], descriptions[1], "Verify amended behavior"]));
|
|
604
|
+
const amended = await h.call({ action: "refresh", path });
|
|
605
|
+
assert.equal(amended.details.tasks[2].name, "row 3");
|
|
606
|
+
assert.equal(amended.details.tasks[2].label, "2/4: Verify amended behavior");
|
|
607
|
+
assert.equal(amended.details.tasks.length, 3);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
test("coordinated labels use mapped original AC indices, including shared criteria", async () => {
|
|
611
|
+
const dir = ledgerFixture("plan-original-ac-");
|
|
612
|
+
const path = join(dir, "ledger.md");
|
|
613
|
+
writeFileSync(path, ledger(["1 | cut | 0 | cut", "2 | open | 1 | check", "3 | open | 0 | check"], [
|
|
614
|
+
"## Leg mapping", "| row | ac | leg |", "|---|---|---|",
|
|
615
|
+
"| 1 | 2 | repo-a |", "| 2 | 3 | repo-a |", "| 3 | 3 | repo-b |",
|
|
616
|
+
].join("\n"), "allowance: 4"));
|
|
617
|
+
const result = await harness().call({ action: "refresh", path });
|
|
618
|
+
assert.equal(result.details.error, undefined);
|
|
619
|
+
assert.deepEqual(result.details.tasks.map(t => [t.name, t.label]), [
|
|
620
|
+
["row 1 (repo-a)", `${descriptions[1]} (repo-a)`],
|
|
621
|
+
["row 2 (repo-a)", `1/4: ${descriptions[2]} (repo-a)`],
|
|
622
|
+
["row 3 (repo-b)", `0/4: ${descriptions[2]} (repo-b)`],
|
|
623
|
+
]);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test("refresh rejects an unresolvable repository root without changing initialized tasks", async (t) => {
|
|
627
|
+
const dir = mkdtempSync(join(tmpdir(), "plan-no-checkout-"));
|
|
628
|
+
try {
|
|
629
|
+
execFileSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], { stdio: "ignore" });
|
|
630
|
+
t.skip("temporary directory is inside a git repository");
|
|
631
|
+
return;
|
|
632
|
+
} catch {}
|
|
633
|
+
writeFileSync(join(dir, "spec.md"), spec(["Keep prior tasks"]));
|
|
634
|
+
const path = join(dir, "ledger.md");
|
|
635
|
+
writeFileSync(path, ledger(["1 | open | 0 |"], "", "allowance: 4"));
|
|
636
|
+
const h = harness([], dir);
|
|
637
|
+
const initial = await h.call({ action: "init", tasks: [{ name: "G1: closed", status: "complete" }, "G2: pending"] });
|
|
638
|
+
const result = await h.call({ action: "refresh", path });
|
|
639
|
+
assert.match(result.details.error!, /cannot resolve repository root for Spec reference/);
|
|
640
|
+
assert.equal(result.details.tasks, undefined);
|
|
641
|
+
assert.deepEqual((await h.call({ action: "status" })).details.tasks, initial.details.tasks);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("refresh rejects missing specs, missing criteria and empty descriptions atomically", async () => {
|
|
645
|
+
const dir = ledgerFixture("plan-spec-errors-");
|
|
646
|
+
const path = join(dir, "ledger.md");
|
|
647
|
+
const valid = ledger(["1 | open | 0 | check"], "", "allowance: 4");
|
|
648
|
+
writeFileSync(path, valid);
|
|
649
|
+
const h = harness();
|
|
650
|
+
const initial = await h.call({ action: "refresh", path });
|
|
651
|
+
assert.equal(initial.details.error, undefined);
|
|
652
|
+
const widget = h.widget();
|
|
653
|
+
const cases = [
|
|
654
|
+
{ text: valid.replace("**Spec:** `spec.md`", ""), spec: spec(), error: /missing Spec reference/ },
|
|
655
|
+
{ text: valid.replace("`spec.md`", "`absent.md`"), spec: spec(), error: /cannot read spec .*absent\.md:/ },
|
|
656
|
+
{ text: valid, spec: "# Spec\n## Design\n- [ ] Not an AC", error: /row 1: spec .*spec\.md AC 1 is missing/ },
|
|
657
|
+
{ text: valid, spec: spec([" \n deferred: #1"]), error: /row 1: spec .*spec\.md AC 1 has an empty description/ },
|
|
658
|
+
{ text: ledger(["7 | open | 0 | check"]), spec: spec(), error: /row 7: spec .*spec\.md AC 7 is missing/ },
|
|
659
|
+
{ text: valid + "\n## Leg mapping\n| row | ac | leg |\n|---|---|---|\n| 1 | 9 | repo-a |", spec: spec(), error: /row 1: spec .*spec\.md AC 9 is missing/ },
|
|
660
|
+
];
|
|
661
|
+
for (const row of cases) {
|
|
662
|
+
writeFileSync(path, row.text);
|
|
663
|
+
writeFileSync(join(dir, "spec.md"), row.spec);
|
|
664
|
+
const result = await h.call({ action: "refresh", path });
|
|
665
|
+
assert.match(result.details.error!, row.error);
|
|
666
|
+
assert.equal(result.details.tasks, undefined);
|
|
667
|
+
assert.equal(h.widget(), widget);
|
|
668
|
+
assert.deepEqual((await h.call({ action: "status" })).details.tasks, initial.details.tasks);
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
test("status rows round-trip through the brief identity grammar without description drift or duplicate tasks", async () => {
|
|
673
|
+
const contract = readFileSync(new URL("../skills/gauntlet-resume/reference/brief-contract.md", import.meta.url), "utf8");
|
|
674
|
+
const suffix = new RegExp(contract.match(/suffix pattern `([^`]+)`/)![1]);
|
|
675
|
+
const prefix = new RegExp(contract.match(/prefix pattern `([^`]+)`/)![1]);
|
|
676
|
+
const canonical = new RegExp(contract.match(/canonical pattern `([^`]+)`/)![1]);
|
|
677
|
+
const restoreName = (name: string) => {
|
|
678
|
+
const labeled = suffix.exec(name);
|
|
679
|
+
if (labeled) return labeled[1];
|
|
680
|
+
const plain = name.replace(prefix, "");
|
|
681
|
+
return canonical.test(plain) ? plain : name;
|
|
682
|
+
};
|
|
683
|
+
for (const [input, expected] of [
|
|
684
|
+
["row 3", "row 3"], ["2/5: row 3", "row 3"], ["2/5: row 3 (repo-b)", "row 3 (repo-b)"],
|
|
685
|
+
["row 3 (repo-b) | 2/5: Verify | row 99", "row 3 (repo-b)"],
|
|
686
|
+
["2/5: G3: pending", "2/5: G3: pending"], ["G1: row 3 | description", "G1: row 3 | description"],
|
|
687
|
+
["row 3 notes | description", "row 3 notes | description"],
|
|
688
|
+
]) assert.equal(restoreName(input), expected);
|
|
689
|
+
const dir = ledgerFixture("plan-brief-roundtrip-");
|
|
690
|
+
const path = join(dir, "ledger.md");
|
|
691
|
+
writeFileSync(join(dir, "spec.md"), spec([descriptions[0], descriptions[1], `${descriptions[2]} | row 99 is text`]));
|
|
692
|
+
const text = ledger(["2 | cut | 0 | cut", "3 | done | 2 | passed"], "row 3 done | passed | none", "allowance: 5");
|
|
693
|
+
writeFileSync(path, text);
|
|
694
|
+
const h = harness();
|
|
695
|
+
await h.call({ action: "init", tasks: [{ name: "G1: failed", status: "failed" }, { name: "G2: repair", status: "in_progress" }, "2/5: G3: pending"] });
|
|
696
|
+
await h.call({ action: "refresh", path });
|
|
697
|
+
const status = await h.call({ action: "status" });
|
|
698
|
+
const brief = status.content[0].text;
|
|
699
|
+
const statuses: Record<string, string> = { "\u2713": "complete", "\u2192": "in_progress", "\u2717": "failed", "\u2298": "skipped", "\u25cb": "pending" };
|
|
700
|
+
const restored = [...brief.matchAll(/^\s*([\u2713\u2192\u2717\u2298\u25cb]) \[\d+\] (.+)$/gm)]
|
|
701
|
+
.map(([, glyph, name]) => ({ name: restoreName(name), status: statuses[glyph] }));
|
|
702
|
+
assert.deepEqual(restored, status.details.tasks.map(({ name, status }) => ({ name, status })));
|
|
703
|
+
const resumed = harness();
|
|
704
|
+
const init = await resumed.call({ action: "init", tasks: restored });
|
|
705
|
+
assert.equal(init.details.error, undefined);
|
|
706
|
+
assert.ok(init.details.tasks.every(t => !Object.hasOwn(t, "label")));
|
|
707
|
+
writeFileSync(path, text.replace("allowance: 5", "allowance: 6"));
|
|
708
|
+
const refreshed = await resumed.call({ action: "refresh", path });
|
|
709
|
+
assert.deepEqual(refreshed.details.tasks.map(({ name, status }) => ({ name, status })), restored);
|
|
710
|
+
assert.match(refreshed.content[0].text, /row 3 \| 2\/6: Verify loop behavior and recovery \| row 99 is text/);
|
|
711
|
+
assert.equal(refreshed.details.tasks.filter(t => t.name === "row 3").length, 1);
|
|
712
|
+
});
|
|
713
|
+
|
|
452
714
|
test("refresh rejects unreadable or malformed ledgers atomically, including absent numeric pending rows", async () => {
|
|
453
|
-
const dir =
|
|
715
|
+
const dir = ledgerFixture("plan-refresh-error-");
|
|
454
716
|
const path = join(dir, "ledger.md");
|
|
455
717
|
const valid = ledger(["1 | open | 0 |"]);
|
|
456
718
|
const h = harness();
|
|
@@ -461,8 +723,9 @@ test("refresh rejects unreadable or malformed ledgers atomically, including abse
|
|
|
461
723
|
{ name: "unreadable path", path: join(dir, "absent.md"), text: valid, error: /refresh: cannot read .*absent\.md:/ },
|
|
462
724
|
{ name: "wrong discriminator", path, text: "# Plan", error: /line 1 is "# Plan", not a loop ledger/ },
|
|
463
725
|
{ name: "missing header", path, text: valid.replace("| row | status | attempts | evidence |", "| row | state | attempts | evidence |"), error: /row table malformed at line \d+/ },
|
|
464
|
-
{ name: "nonnumeric row", path, text: valid.replace("| 1 | open", "| x | open"), error: /row table malformed at line
|
|
465
|
-
{ name: "invalid status", path, text: valid.replace("| 1 | open", "| 1 | unknown"), error: /row table malformed at line
|
|
726
|
+
{ name: "nonnumeric row", path, text: valid.replace("| 1 | open", "| x | open"), error: /row table malformed at line 6/ },
|
|
727
|
+
{ name: "invalid status", path, text: valid.replace("| 1 | open", "| 1 | unknown"), error: /row table malformed at line 6/ },
|
|
728
|
+
...["x", "-1", "1.5", ""].map(attempts => ({ name: `invalid attempts ${attempts}`, path, text: valid.replace("| 0 |", `| ${attempts} |`), error: /row table malformed at line 6/ })),
|
|
466
729
|
{ name: "absent pending row", path, text: "pending: 2 | home | abc1234", error: /pending: names row 2 which is absent/ },
|
|
467
730
|
{ name: "zero pending row", path, text: "pending: 0 | home | abc1234", error: /pending: names row 0 which is absent/ },
|
|
468
731
|
{ name: "leading-zero absent row", path, text: "pending: 02 | home | abc1234", error: /pending: names row 2 which is absent/ },
|