@sabaiway/agent-workflow-kit 12.1.0 → 12.3.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/CHANGELOG.md CHANGED
@@ -4,6 +4,61 @@ Semantically versioned ([semver](https://semver.org)), newest first. The `versio
4
4
  is the current release. `upgrade` mode reads a project's `docs/ai/.workflow-version` and applies
5
5
  every `migrations/<version>-<slug>.md` newer than it, in semver order.
6
6
 
7
+ ## 12.3.0 — the purge archive is a tool: `queue-purge` snapshots a section's bytes and vouches for what leaves, and `queue-audit` exports the two surfaces it reads (AD-137)
8
+
9
+ **MINOR: one new tool, two verbs, and the sibling's revision 6; no deployed gate line changes its verdict.** The canon has
10
+ demanded a purge archive since the row grammar was written, as prose kept by hand — eight dated archives sat under
11
+ `docs/ai/history/` tied by no checked fact to the queue they came out of. The rung is now a tool, and the contract is
12
+ `docs/ai/specs/kit/queue-purge/` (live, revision 2, S1–S18 bound).
13
+
14
+ - **`tools/queue-purge-cli.mjs snapshot <queue> --section <heading> --archive <file>`** writes the section's
15
+ heading line and range VERBATIM from the file's own bytes — every row, the preamble, the bucket headings — plus one
16
+ index entry per row (archived line, class, key, the title's first 80 characters) under a header that records the
17
+ date, the canonical section, the queue path, both byte lengths, the section sha256, the block sha256 (sealing every
18
+ other header field and the whole body) and the row count. A block is FRAMED by its recorded lengths, never found by
19
+ scanning backward; the archive grows by appended blocks, whole or not at all; a second snapshot of an unmoved
20
+ section refuses; a keyless row is archived and marked, with the count on stderr. `--check` reads the archive whole,
21
+ judges it intact, selects the last block for the section and refuses on exactly six causes — a key defect, not
22
+ intact, not moved, an addition, an unauthorised absence, a keyless entry — collected, each naming its row or offset.
23
+ The key of a row is its id and nothing else; a doubled id anywhere in the document refuses both verbs naming both
24
+ lines. Exit 0 accept · 1 refuse · 2 usage; `--archive` and `--section` are both required, every option and
25
+ positional at most once, `--help` only whole, an archive that resolves to the queue (path, symlink or hard link)
26
+ is usage, and the eight hand-written dated archives are foreign grammar (exit 2 naming line 1). Neither verb reads
27
+ a cap or judges a length or a name; the queue is byte-identical after both. Three modules under the 400-line cap —
28
+ `queue-purge-archive.mjs` (the grammar, the digests, the append-whole writer), `queue-purge.mjs` (the two verbs
29
+ over ONE document-wide parse), `queue-purge-cli.mjs` — and four suites under `tools/queue-purge.test/`.
30
+ - **`queue-audit` revision 6 (`docs/ai/specs/kit/queue-audit/`, now a folder: index + `argv.md`).**
31
+ `authorisesRemoval(klass)` is exported from the row leaf — true for `terminal` and `record` only, false for every
32
+ other value, an unknown class included — and `checkQueue`'s still-listed refusal reads it, as the purge check does
33
+ over the class an archive block froze: two callers, one list. `auditQueue(text, { range })` — exclusive with
34
+ `section` — keeps the rows, counts, total and the unread-item refusal DOCUMENT-WIDE and adds
35
+ `range: { headingLine, from, to }` in file lines (half-open, an empty section included); a `section` call is
36
+ byte-identical to before, so a deployed gate line keeps its verdict. Scenarios S21 and S22 bind in
37
+ `queue-audit.test/check-queue.test.mjs`.
38
+ - **The tarball** gains the three modules by name (297 files, from 294); the suites stay out of it.
39
+
40
+ Repo-only, the tool's first live run: this repo's `## Pending / backlog` went from 257 rows / 7020 lines to 256 / 1717
41
+ in the order the spec names — 8 doubled ids renamed, 120 keyless rows keyed, 8 ambiguous rows settled with no line
42
+ added or deleted, one snapshot (257 rows, 655095 section bytes, no keyless note), five trim slices by bucket with
43
+ every id byte-identical and one terminal row deleted, `--check` accepting at 256 rows; eight rows this release's own
44
+ review filed after the check took the count to 264, so the gate line reads `--max-rows 264 --max-row-lines 10` under
45
+ a one-sentence title with no ratchet log.
46
+
47
+ ## 12.2.0 — the bundled agy bridge defaults to Gemini 3.8 Flash (High) (AD-136; antigravity-cli-bridge 5.7.0)
48
+
49
+ **MINOR: a default moved, nothing was removed.** The kit bundles the agy bridge mirror, so the bridge's pin move rides
50
+ this release; no kit tool changed behaviour.
51
+
52
+ - **`Gemini 3.8 Flash (High)` is the bridge default for both roles** — `agy-run` and `agy-review` — and joins the review
53
+ wrapper's frontier set. The string is what `agy models` on the installed CLI (1.1.28, probed 2026-09-09) prints; a model
54
+ display string is matched exactly, so a stale default is a dispatch that refuses before it spends anything (AD-095).
55
+ `Gemini 3.7 Flash (High)` stays in the frontier set: a host that pins it explicitly keeps an advisory-silent council
56
+ review. Any model remains selectable through `AGY_MODEL`; the sub-frontier advisory is still a warning, never a gate.
57
+ - **The catalog tables gain the three 3.8 rows**, enumerated individually, in the skill, the model reference and the
58
+ driving playbook; the wrapper's header inventory names the 3.8 family. No row is removed.
59
+ - **The posture render follows the pin.** The status line and the D5 posture footer read the bundled manifest, so they
60
+ now print `agy model=Gemini 3.8 Flash (High)`; the posture-parity test pins the pair.
61
+
7
62
  ## 12.1.0 — a queue row carries a plain name a person can read, and `now` renders where the work stands from the evidence, never from the plan's own text (AD-134)
8
63
 
9
64
  **MINOR: two additive surfaces, one series, one new refusal.** The name judge is advisory until a gate line opts in, and `now` is
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: agent-workflow-kit
3
3
  description: Deploy or upgrade a portable AI-agent memory-and-workflow system in any project. Use when the user wants to bootstrap `docs/ai/` + an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) + cap/archive/index enforcement in a new or existing repo, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-kit` / `/agent-workflow-kit upgrade`. Triggers on phrases like "set up the memory system", "deploy the AI workflow here", "bootstrap docs/ai", "upgrade the workflow".
4
4
  disable-model-invocation: true
5
5
  metadata:
6
- version: '12.1.0'
6
+ version: '12.3.0'
7
7
  ---
8
8
 
9
9
  # agent-workflow-kit
@@ -2,7 +2,7 @@
2
2
  name: antigravity-cli-bridge
3
3
  description: Delegate work to Google's Antigravity CLI (`agy`) — the successor to Gemini CLI — to reach Gemini, Claude, and GPT-OSS models under a Google AI Pro/Ultra subscription from the terminal. Use when the user wants to run a headless `agy` prompt, hand a focused task or second-opinion review to `agy`, install or authenticate Antigravity CLI, check or economise its quota/models, bridge project context into `agy`, set up a second delegated-execution backend beside Codex, or troubleshoot `agy` flags, models, auth, conversations, or its headless behaviour.
4
4
  metadata:
5
- version: '5.6.0'
5
+ version: '5.7.0'
6
6
  ---
7
7
 
8
8
  # antigravity-cli-bridge
@@ -53,20 +53,23 @@ keep probes short (see *How the main agent drives agy*).
53
53
  ## Models
54
54
 
55
55
  Pass the **exact display string** to `--model` (or set `AGY_MODEL`). The wrapper defaults to
56
- `Gemini 3.7 Flash (High)`. Run `agy models` for the live list — if it differs from this table, the
56
+ `Gemini 3.8 Flash (High)`. Run `agy models` for the live list — if it differs from this table, the
57
57
  live list wins.
58
58
 
59
59
  | Model string | Use it for |
60
60
  |---|---|
61
- | `Gemini 3.7 Flash (Low)` | cheapest; reachability checks, smoke tests, simple transforms |
62
- | `Gemini 3.7 Flash (Medium)` | cheap probes, context-reachability checks, quick summaries |
63
- | `Gemini 3.7 Flash (High)` | wrapper + review default; asserted frontier-grade (fork (a)) |
64
- | `Gemini 3.6 Flash (Low)` | previous Flash generation, still served — prefer 3.7 |
65
- | `Gemini 3.6 Flash (Medium)` | previous Flash generation, still served — prefer 3.7 |
66
- | `Gemini 3.6 Flash (High)` | previous Flash generation, still served — prefer 3.7 |
67
- | `Gemini 3.5 Flash (Low)` | older Flash generation, still served — prefer 3.7 |
68
- | `Gemini 3.5 Flash (Medium)` | older Flash generation, still served — prefer 3.7 |
69
- | `Gemini 3.5 Flash (High)` | older Flash generation, still served — prefer 3.7 |
61
+ | `Gemini 3.8 Flash (Low)` | cheapest; reachability checks, smoke tests, simple transforms |
62
+ | `Gemini 3.8 Flash (Medium)` | cheap probes, context-reachability checks, quick summaries |
63
+ | `Gemini 3.8 Flash (High)` | wrapper + review default; asserted frontier-grade (AD-136) |
64
+ | `Gemini 3.7 Flash (Low)` | previous Flash generation, still served — prefer 3.8 |
65
+ | `Gemini 3.7 Flash (Medium)` | previous Flash generation, still served — prefer 3.8 |
66
+ | `Gemini 3.7 Flash (High)` | previous Flash generation, still frontier-grade for review (fork (a)) |
67
+ | `Gemini 3.6 Flash (Low)` | older Flash generation, still served — prefer 3.8 |
68
+ | `Gemini 3.6 Flash (Medium)` | older Flash generation, still served — prefer 3.8 |
69
+ | `Gemini 3.6 Flash (High)` | older Flash generation, still served — prefer 3.8 |
70
+ | `Gemini 3.5 Flash (Low)` | older Flash generation, still served — prefer 3.8 |
71
+ | `Gemini 3.5 Flash (Medium)` | older Flash generation, still served — prefer 3.8 |
72
+ | `Gemini 3.5 Flash (High)` | older Flash generation, still served — prefer 3.8 |
70
73
  | `Gemini 3.1 Pro (Low)` | cheaper Pro pass for medium reasoning |
71
74
  | `Gemini 3.1 Pro (High)` | hard reasoning, plan critique, architecture review (slower, deeper) |
72
75
  | `Claude Sonnet 4.6 (Thinking)` | a Claude second opinion through the same subscription |
@@ -100,7 +103,7 @@ can ride passthrough (`-- --output-format json`) but this wrapper adds no parsin
100
103
  you need structure, ask for Markdown with explicit headings and validate it yourself. (The `review`
101
104
  role is the one that adopted the JSON envelope — see below.) Wrapper inputs: first argument is the
102
105
  prompt (`text`, `-` for stdin, or `@file`);
103
- `AGY_MODEL` (default `Gemini 3.7 Flash (High)`); `AGY_TIMEOUT` → `--print-timeout` (default `5m`);
106
+ `AGY_MODEL` (default `Gemini 3.8 Flash (High)`); `AGY_TIMEOUT` → `--print-timeout` (default `5m`);
104
107
  `AGY_HARD_TIMEOUT` → hard `timeout(1)` wall-clock cap (default = `AGY_TIMEOUT`); extra `agy` flags
105
108
  after `--`. Full detail: [`references/models-and-flags.md`](references/models-and-flags.md).
106
109
 
@@ -189,7 +192,7 @@ release). `agy-review … --nonce <n>` is the plain-argument equivalent
189
192
  (one seam; flag and a non-empty env must agree, a disagreeing pair refuses pre-spend) — the lane
190
193
  for hosts whose dispatch policy has no env-prefix form.
191
194
 
192
- Frontier default `Gemini 3.7 Flash (High)`; **any** model is allowed (a sub-frontier one earns a
195
+ Frontier default `Gemini 3.8 Flash (High)`; **any** model is allowed (a sub-frontier one earns a
193
196
  silenceable `AGY_PROBE=1` advisory). An oversized `code` review is **DELIVERED, not refused**: the
194
197
  change set is cut into under-cap parts, fed over continuation turns and reviewed in a final turn, and
195
198
  the answer must reproduce a line the wrapper picked from each part — a missing or wrong echo is a
@@ -35,7 +35,7 @@
35
35
  # agy-review --conversation <id> [--decided @f] [--focus "…"] # resume a specific conversation
36
36
  #
37
37
  # Environment (every optional var has an explicit default so a no-env run is safe under set -u):
38
- # AGY_MODEL default "Gemini 3.7 Flash (High)"; ANY model allowed (advisory warn off-frontier).
38
+ # AGY_MODEL default "Gemini 3.8 Flash (High)"; ANY model allowed (advisory warn off-frontier).
39
39
  # Set empty (AGY_MODEL=) to drop --model and use agy's settings.json.
40
40
  # AGY_HARD_TIMEOUT default 30m (duration string; the timeout(1) hard cap via agy-run)
41
41
  # AGY_TIMEOUT default = AGY_HARD_TIMEOUT (agy's soft --print-timeout)
@@ -339,11 +339,11 @@ aw_resolve_timeout_bin() {
339
339
  printf '%s' "$bin"
340
340
  }
341
341
 
342
- DEFAULT_AGY_REVIEW_MODEL="Gemini 3.7 Flash (High)"
342
+ DEFAULT_AGY_REVIEW_MODEL="Gemini 3.8 Flash (High)"
343
343
  # Review-receipt identity (AD-038). AW_BRIDGE_VERSION mirrors this bridge's SKILL.md/capability.json
344
344
  # version (drift-guarded by agy-review.test.mjs against capability.json).
345
345
  AW_RECEIPT_BACKEND="agy"
346
- AW_BRIDGE_VERSION="5.6.0" # aw-version-anchor
346
+ AW_BRIDGE_VERSION="5.7.0" # aw-version-anchor
347
347
  # `-` not `:-` so an EXPLICIT empty AGY_MODEL= survives (drop --model, use settings.json — agy.sh:52).
348
348
  AGY_MODEL="${AGY_MODEL-$DEFAULT_AGY_REVIEW_MODEL}"
349
349
  # D5 control-byte screen — IMMEDIATELY after resolution, BEFORE the off-frontier advisory (or any
@@ -353,8 +353,9 @@ if [[ "$AGY_MODEL" == *[$'\x01'-$'\x1f'$'\x7f']* ]]; then
353
353
  exit 2
354
354
  fi
355
355
  # Frontier review models. ANY model is allowed; a sub-frontier one only earns a soft, silenceable
356
- # warning. Gemini 3.7 Flash (High) is asserted frontier-grade (fork (a), maintainer 2026-08-14).
357
- FRONTIER_SET=("Gemini 3.7 Flash (High)" "Gemini 3.1 Pro (High)" "Claude Opus 4.6 (Thinking)" "Claude Sonnet 4.6 (Thinking)")
356
+ # warning. Gemini 3.8 Flash (High) is asserted frontier-grade (AD-136, maintainer 2026-09-09); 3.7 Flash
357
+ # (High) stays in the set (fork (a), maintainer 2026-08-14).
358
+ FRONTIER_SET=("Gemini 3.8 Flash (High)" "Gemini 3.7 Flash (High)" "Gemini 3.1 Pro (High)" "Claude Opus 4.6 (Thinking)" "Claude Sonnet 4.6 (Thinking)")
358
359
 
359
360
  # Duration-string timeouts (NOT codex's bare seconds): agy-run forwards a duration to --print-timeout,
360
361
  # and the timeout(1) hard cap is a duration too — never numerically compared, so 30m vs 2h is fine.
@@ -42,10 +42,10 @@ describe('agy-review.sh — model policy advisory (1)', { concurrency: 2 }, () =
42
42
  rmSync(sb.home, { recursive: true, force: true });
43
43
  assert.equal(r.status, 0, r.stderr);
44
44
  assert.doesNotMatch(r.stderr, /non-frontier model/);
45
- assert.match(r.argv, /Gemini 3\.7 Flash \(High\)/, 'the frontier default reaches agy');
45
+ assert.match(r.argv, /Gemini 3\.8 Flash \(High\)/, 'the frontier default reaches agy');
46
46
  });
47
47
 
48
- it('an explicit Gemini 3.7 Flash (High) is FRONTIER — no advisory (fork (a), maintainer 2026-08-14)', async () => {
48
+ it('an explicit Gemini 3.7 Flash (High) stays FRONTIER after the 3.8 pin move — no advisory (AD-136)', async () => {
49
49
  const sb = makeSandbox();
50
50
  const r = await run(sb, { args: ['code', '--facts', 'a tiny fact'], env: { AGY_MODEL: 'Gemini 3.7 Flash (High)' } });
51
51
  rmSync(sb.home, { recursive: true, force: true });
@@ -2776,8 +2776,8 @@ describe('agy-review.sh — dispatch-posture labeling (D5)', { concurrency: 2 },
2776
2776
  const receipts = readReceipts(sb.repo);
2777
2777
  rmSync(sb.home, { recursive: true, force: true });
2778
2778
  assert.equal(r.status, 0, r.stderr);
2779
- assert.match(r.stderr, /review posture: model=Gemini 3\.7 Flash \(High\)/, 'the banner states the actual run posture');
2780
- assert.deepEqual(receipts[0].posture, { model: 'Gemini 3.7 Flash (High)' }, 'banner ↔ receipt parity');
2779
+ assert.match(r.stderr, /review posture: model=Gemini 3\.8 Flash \(High\)/, 'the banner states the actual run posture');
2780
+ assert.deepEqual(receipts[0].posture, { model: 'Gemini 3.8 Flash (High)' }, 'banner ↔ receipt parity');
2781
2781
  assert.deepEqual(Object.keys(receipts[0]), Object.keys(RECEIPT_FIXTURE), 'fixture key set + order');
2782
2782
  });
2783
2783
 
@@ -2830,7 +2830,7 @@ describe('agy-review.sh — dispatch-posture labeling (D5)', { concurrency: 2 },
2830
2830
  const receipts = readReceipts(sb.repo);
2831
2831
  rmSync(sb.home, { recursive: true, force: true });
2832
2832
  assert.equal(r.status, 0, r.stderr);
2833
- assert.match(r.stderr, /^review posture: model=Gemini 3\.7 Flash \(High\) timeout=30m$/m);
2833
+ assert.match(r.stderr, /^review posture: model=Gemini 3\.8 Flash \(High\) timeout=30m$/m);
2834
2834
  assert.deepEqual(Object.keys(receipts[0].posture), ['model'], 'timeout never enters the receipt posture');
2835
2835
  });
2836
2836
 
@@ -19,8 +19,8 @@
19
19
  #
20
20
  # Model families (compact inventory only; run `agy models` for exact display strings, then pass
21
21
  # one via AGY_MODEL):
22
- # Gemini 3.7 Flash (Low|Medium|High), Gemini 3.6 Flash (Low|Medium|High),
23
- # Gemini 3.5 Flash (Low|Medium|High), Gemini 3.1 Pro (Low|High),
22
+ # Gemini 3.8 Flash (Low|Medium|High), Gemini 3.7 Flash (Low|Medium|High),
23
+ # Gemini 3.6 Flash (Low|Medium|High), Gemini 3.5 Flash (Low|Medium|High), Gemini 3.1 Pro (Low|High),
24
24
  # Claude Sonnet 4.6 (Thinking), Claude Opus 4.6 (Thinking), GPT-OSS 120B (Medium)
25
25
  #
26
26
  # Usage (installed on PATH as `agy-run`):
@@ -240,8 +240,8 @@ if ! command -v agy >/dev/null 2>&1; then
240
240
  fi
241
241
 
242
242
  # `-` (empty) => skip --model and let agy use settings.json; default to Flash (High) — asserted
243
- # frontier-grade (fork (a), maintainer 2026-08-14).
244
- AGY_MODEL="${AGY_MODEL-Gemini 3.7 Flash (High)}"
243
+ # frontier-grade (fork (a), maintainer 2026-08-14; the 3.8 pin AD-136, 2026-09-09).
244
+ AGY_MODEL="${AGY_MODEL-Gemini 3.8 Flash (High)}"
245
245
  AGY_TIMEOUT="${AGY_TIMEOUT:-5m}"
246
246
  AGY_TIMEOUT="$(aw_effective_timeout AGY_TIMEOUT 5m)"
247
247
  # Hard wall-clock cap (defaults to AGY_TIMEOUT). agy's own --print-timeout is NOT a reliable
@@ -449,7 +449,7 @@ describe('agy.sh — bridge settings file (bridges 2.3.0)', { concurrency: true
449
449
 
450
450
  // ── the wrapper's OWN default model (fork (a), round-1 council F3) ────────────────
451
451
  describe('agy.sh — default model pin', () => {
452
- it('absent AGY_MODEL → the stub argv carries --model Gemini 3.7 Flash (High)', () => {
452
+ it('absent AGY_MODEL → the stub argv carries --model Gemini 3.8 Flash (High)', () => {
453
453
  const home = makeSandbox(ARGV_STUB);
454
454
  const argvFile = join(home, 'argv.txt');
455
455
  const r = runWrapper(home, { AGY_STUB_ARGV: argvFile });
@@ -457,7 +457,7 @@ describe('agy.sh — default model pin', () => {
457
457
  rmSync(home, { recursive: true, force: true });
458
458
  assert.equal(r.status, 0, r.stderr);
459
459
  assert.match(argv, /^--model$/m, 'the default run passes --model');
460
- assert.match(argv, /^Gemini 3\.7 Flash \(High\)$/m, 'the wrapper default is the probed display string');
460
+ assert.match(argv, /^Gemini 3\.8 Flash \(High\)$/m, 'the wrapper default is the probed display string');
461
461
  });
462
462
 
463
463
  it("explicit AGY_MODEL='' drops --model entirely (settings.json takes over)", () => {
@@ -3,9 +3,9 @@
3
3
  "schema": 1,
4
4
  "name": "antigravity-cli-bridge",
5
5
  "kind": "execution-backend",
6
- "version": "5.6.0",
6
+ "version": "5.7.0",
7
7
  "provides": ["review", "probe"],
8
- "posture": { "model": "Gemini 3.7 Flash (High)" },
8
+ "posture": { "model": "Gemini 3.8 Flash (High)" },
9
9
  "roles": {
10
10
  "review": {
11
11
  "cmd": "agy-review",
@@ -181,7 +181,7 @@
181
181
  ],
182
182
  "guardrails": [
183
183
  { "value": "subscription-only — every *_API_KEY env var is unset before the run", "enforcement": "enforced", "source": "bin/agy.sh" },
184
- { "value": "any model is selectable via AGY_MODEL; the default is Gemini 3.7 Flash (High)", "enforcement": "advisory", "source": "bin/agy.sh" },
184
+ { "value": "any model is selectable via AGY_MODEL; the default is Gemini 3.8 Flash (High)", "enforcement": "advisory", "source": "bin/agy.sh" },
185
185
  { "value": "hard wall-clock cap AGY_HARD_TIMEOUT (built-in default 5m)", "enforcement": "enforced", "condition": "only while timeout(1)/gtimeout is on PATH — otherwise the wrapper warns and runs uncapped", "source": "capability.json settings.AGY_HARD_TIMEOUT" },
186
186
  { "value": "the prompt rides a single argv — over AGY_MAX_PROMPT_BYTES (120000) the run refuses", "enforcement": "enforced", "source": "bin/agy.sh" },
187
187
  { "value": "no review posture, no grounding, no receipt — the reply is raw model output", "enforcement": "advisory", "source": "bin/agy.sh" }
@@ -18,14 +18,15 @@ edits, verification, and final judgment.
18
18
 
19
19
  | Task | Model |
20
20
  |---|---|
21
- | Reachability / smoke / "is it wired?" | `Gemini 3.7 Flash (Low)` |
22
- | Cheap probes, summaries | `Gemini 3.7 Flash (Medium)` |
23
- | Review, reasoning, careful drafting | `Gemini 3.7 Flash (High)` (wrapper default; asserted frontier-grade) |
21
+ | Reachability / smoke / "is it wired?" | `Gemini 3.8 Flash (Low)` |
22
+ | Cheap probes, summaries | `Gemini 3.8 Flash (Medium)` |
23
+ | Review, reasoning, careful drafting | `Gemini 3.8 Flash (High)` (wrapper default; asserted frontier-grade, AD-136) |
24
+ | The previous Flash generation, still frontier-grade for review | `Gemini 3.7 Flash (High)` (fork (a)) |
24
25
  | Slower, deeper reasoning pass | `Gemini 3.1 Pro (High)` |
25
26
  | Same Pro reasoning, lower quota cost | `Gemini 3.1 Pro (Low)` |
26
27
  | A different engine's opinion | `Claude Sonnet 4.6 (Thinking)`, `Claude Opus 4.6 (Thinking)`, or `GPT-OSS 120B (Medium)` |
27
28
 
28
- The 3.6 / 3.5 Flash families remain served — prefer 3.7 (newest generation at the same tiers).
29
+ The 3.7 / 3.6 / 3.5 Flash families remain served — prefer 3.8 (newest generation at the same tiers).
29
30
  Don't reach for Pro by reflex — Flash answers most reachability/probe questions for a fraction of the
30
31
  quota.
31
32
 
@@ -95,7 +96,7 @@ What it does for you, and what YOU must supply:
95
96
  `AGY_REVIEW_ALLOW_ADDDIR` is **RETIRED** (recognized, arms nothing): headless `agy` auto-denies its
96
97
  own `read_file`, so the offload it armed could return a confident fabrication with no way to tell.
97
98
  The kit never grants that permission — the feed exists so none is needed.
98
- - **Model:** frontier default `Gemini 3.7 Flash (High)`; any model is allowed (a sub-frontier one earns a
99
+ - **Model:** frontier default `Gemini 3.8 Flash (High)`; any model is allowed (a sub-frontier one earns a
99
100
  silenceable `AGY_PROBE=1` advisory). The service can still **stall on large/substantive prompts**
100
101
  (Issue-001) — keep reviews **focused**; the hard timeout is the guard.
101
102
  - **Posture banner — quote it verbatim.** Every review states its ACTUAL posture on ONE stderr line
@@ -46,7 +46,7 @@ Environment:
46
46
 
47
47
  | Var | Default | Effect |
48
48
  |---|---|---|
49
- | `AGY_MODEL` | `Gemini 3.7 Flash (High)` | model display string; set empty (`AGY_MODEL=`) to drop `--model` and let `agy` use `settings.json` |
49
+ | `AGY_MODEL` | `Gemini 3.8 Flash (High)` | model display string; set empty (`AGY_MODEL=`) to drop `--model` and let `agy` use `settings.json` |
50
50
  | `AGY_TIMEOUT` | `5m` | value passed to `--print-timeout` |
51
51
  | `AGY_HARD_TIMEOUT` | `= AGY_TIMEOUT` | hard `timeout(1)` wall-clock cap (a duration string) |
52
52
  | `AGY_MAX_PROMPT_BYTES` | `120000` | single-argv byte ceiling. `agy` takes the prompt as ONE `-p` argv; past `MAX_ARG_STRLEN` (~131072) `execve` fails with a cryptic `Argument list too long`. The wrapper measures the resolved `-`/`@file` prompt and fails loud over the ceiling. A huge **literal** `agy-run "<huge>"` fails at the wrapper's own `exec`, so route large prompts via `-`/`@file`. |
@@ -71,7 +71,7 @@ agy-review --continue | --conversation <id> [--decided @f] [--focus "…"] #
71
71
 
72
72
  | Var | Default | Effect |
73
73
  |---|---|---|
74
- | `AGY_MODEL` | `Gemini 3.7 Flash (High)` | frontier default (fork (a), 2026-08-14); **any** model is allowed — a sub-frontier one earns a silenceable advisory (quality-first, not a gate) |
74
+ | `AGY_MODEL` | `Gemini 3.8 Flash (High)` | frontier default (AD-136, 2026-09-09; 3.7 Flash (High) stays frontier, fork (a)); **any** model is allowed — a sub-frontier one earns a silenceable advisory (quality-first, not a gate) |
75
75
  | `AGY_PROBE` | `0` | `1` silences the off-frontier model advisory AND lets `code` run without `--facts` (an ungrounded probe never attests — its receipt is probe-marked) |
76
76
  | `AGY_REVIEW_MAX_TOTAL_BYTES` | `240000` | the ceiling on the SUM of all outgoing prompt bytes an oversized `code` review's chunked feed may send; checked BEFORE the first turn is spent |
77
77
  | `AGY_REVIEW_ALLOW_ADDDIR` | `0` | **RETIRED** — recognized so an existing settings line never warns as unknown, but it arms nothing. An oversized `code` review is a chunked feed with a per-part delivery proof; the `--add-dir` offload it armed could not be verified (headless `agy` auto-denies `read_file`) |
@@ -97,15 +97,18 @@ Pass the **exact display string** from `agy models`, or set `AGY_MODEL`.
97
97
 
98
98
  | Model string | Practical use |
99
99
  |---|---|
100
- | `Gemini 3.7 Flash (Low)` | lowest-cost smoke tests, cheap probes, simple rewrites (newest Flash) |
101
- | `Gemini 3.7 Flash (Medium)` | fast summaries, context-reachability checks |
102
- | `Gemini 3.7 Flash (High)` | wrapper + review default — asserted frontier-grade (fork (a)) |
103
- | `Gemini 3.6 Flash (Low)` | previous Flash generation, still served — prefer 3.7 |
104
- | `Gemini 3.6 Flash (Medium)` | previous Flash generation, still served — prefer 3.7 |
105
- | `Gemini 3.6 Flash (High)` | previous Flash generation, still served — prefer 3.7 |
106
- | `Gemini 3.5 Flash (Low)` | older Flash generation, still served — prefer 3.7 |
107
- | `Gemini 3.5 Flash (Medium)` | older Flash generation, still served — prefer 3.7 |
108
- | `Gemini 3.5 Flash (High)` | older Flash generation, still served — prefer 3.7 |
100
+ | `Gemini 3.8 Flash (Low)` | lowest-cost smoke tests, cheap probes, simple rewrites (newest Flash) |
101
+ | `Gemini 3.8 Flash (Medium)` | fast summaries, context-reachability checks |
102
+ | `Gemini 3.8 Flash (High)` | wrapper + review default — asserted frontier-grade (AD-136) |
103
+ | `Gemini 3.7 Flash (Low)` | previous Flash generation, still served — prefer 3.8 |
104
+ | `Gemini 3.7 Flash (Medium)` | previous Flash generation, still served — prefer 3.8 |
105
+ | `Gemini 3.7 Flash (High)` | previous Flash generation, still frontier-grade for review (fork (a)) |
106
+ | `Gemini 3.6 Flash (Low)` | older Flash generation, still served — prefer 3.8 |
107
+ | `Gemini 3.6 Flash (Medium)` | older Flash generation, still served — prefer 3.8 |
108
+ | `Gemini 3.6 Flash (High)` | older Flash generation, still served — prefer 3.8 |
109
+ | `Gemini 3.5 Flash (Low)` | older Flash generation, still served — prefer 3.8 |
110
+ | `Gemini 3.5 Flash (Medium)` | older Flash generation, still served — prefer 3.8 |
111
+ | `Gemini 3.5 Flash (High)` | older Flash generation, still served — prefer 3.8 |
109
112
  | `Gemini 3.1 Pro (Low)` | cheaper Pro pass for medium reasoning |
110
113
  | `Gemini 3.1 Pro (High)` | hard reasoning, plan critique, architecture review (slower, deeper) |
111
114
  | `Claude Sonnet 4.6 (Thinking)` | cross-vendor reasoning comparison |
package/capability.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "schema": 1,
4
4
  "name": "agent-workflow-kit",
5
5
  "kind": "composition-root",
6
- "version": "12.1.0",
6
+ "version": "12.3.0",
7
7
  "provides": [],
8
8
  "roles": {},
9
9
  "detect": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sabaiway/agent-workflow-kit",
3
- "version": "12.1.0",
3
+ "version": "12.3.0",
4
4
  "description": "Portable, cross-agent memory & workflow for AI coding agents — Claude Code, Codex, Cursor, Devin Desktop. One command deploys an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement into any repo.",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -49,6 +49,7 @@ export const CLASSES = ['live', 'terminal', 'parked', 'record', 'ambiguous'];
49
49
  // The classes that still carry work, and therefore still cost a reader attention: both caps judge
50
50
  // exactly these.
51
51
  export const CARRY_WORK = new Set(['live', 'parked', 'ambiguous']);
52
+ export const authorisesRemoval = (klass) => klass === 'terminal' || klass === 'record';
52
53
 
53
54
  export const DEFAULTS = { maxRows: 60, maxRowLines: 12 };
54
55
 
@@ -36,12 +36,12 @@
36
36
 
37
37
  import { tokenizeMarkdown, fail } from '../references/scripts/markdown-blocks.mjs';
38
38
  import { bulletBlocks } from './fold-scope.mjs';
39
- import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, classifyRow, titleOf } from './queue-audit-rows.mjs';
39
+ import { CARRY_WORK, CLASSES, DEFAULTS, UNREAD_ITEM, authorisesRemoval, classifyRow, titleOf } from './queue-audit-rows.mjs';
40
40
  import { nameFindings } from './queue-row-name.mjs';
41
41
 
42
42
  // The row grammar is re-exported so one import names the whole reader: the CLI, the tests and any
43
43
  // consumer ask this module, and the split into a rules half stays an implementation detail.
44
- export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, classifyRow, leadMarkers, titleOf } from './queue-audit-rows.mjs';
44
+ export { CLASSES, DEFAULTS, TERMINAL_MARKERS, FROZEN_MARKERS, LIVE_MARKERS, RECORD_PREFIXES, authorisesRemoval, classifyRow, leadMarkers, titleOf } from './queue-audit-rows.mjs';
45
45
  export { nameOf, nameFindings } from './queue-row-name.mjs';
46
46
 
47
47
  // The `[from, to)` body-line window a `--section` names: it opens after that heading and closes at
@@ -83,7 +83,8 @@ const sectionWindow = (headings, lines, section, frontLines = 0) => {
83
83
  // and beside them the fields a RENDERER reads: the row's plain `name`, the `id` the one parse cut
84
84
  // at, the `nameFindings` that name is judged by, and the line-numbered heading `buckets` it sits
85
85
  // under. They are computed here, once, so no consumer becomes a second grammar over this document.
86
- export const auditQueue = (text, { section = null, label = 'the queue' } = {}) => {
86
+ export const auditQueue = (text, { section = null, range = null, label = 'the queue' } = {}) => {
87
+ if (section !== null && range !== null) throw fail(2, 'section and range are exclusive — name only one.');
87
88
  const { lines, headings, fencedLines, frontLines } = tokenizeMarkdown(String(text ?? ''), label);
88
89
  // ONE heading set answers BOTH questions this module asks of a heading — where a `--section` ends,
89
90
  // and which bucket a row sits in — so the rows of the whole document are scanned FIRST and a
@@ -107,6 +108,7 @@ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) =
107
108
  throw fail(2, `line ${frontLines + index + 1} is a heading this document and its row scan read differently ("${lines[index].trim().slice(0, 40)}") — the row above swallows it as body while the document opens a section there, and judging around it would report a domain that was never looked at. Indent it past three spaces to make it body, or to column 0 to make it a heading.`);
108
109
  });
109
110
  const { from, to } = sectionWindow(documentHeadings, lines, section, frontLines);
111
+ const ranged = range === null ? null : sectionWindow(documentHeadings, lines, range, frontLines);
110
112
  // A fence CONTINUES a queue row rather than ending it, and the cap judges the row's PHYSICAL span.
111
113
  // Both halves are the same defect: a row carrying a code block reported one line and hid whatever
112
114
  // followed the fence — its length from the cap, and a closure from the classifier.
@@ -143,7 +145,14 @@ export const auditQueue = (text, { section = null, label = 'the queue' } = {}) =
143
145
  };
144
146
  });
145
147
  const counts = Object.fromEntries(CLASSES.map((klass) => [klass, rows.filter((row) => row.klass === klass).length]));
146
- return { rows, counts, total: rows.length };
148
+ return {
149
+ rows, counts, total: rows.length,
150
+ ...(ranged === null ? {} : { range: {
151
+ headingLine: frontLines + ranged.from,
152
+ from: frontLines + ranged.from + 1,
153
+ to: frontLines + ranged.to + 1 - Number(ranged.to === lines.length && lines.at(-1) === ''),
154
+ } }),
155
+ };
147
156
  };
148
157
 
149
158
  // checkQueue(text, options) -> { ok, problems, notes }. A problem is a REFUSAL and every one of them
@@ -159,7 +168,7 @@ export const checkQueue = (text, options = {}) => {
159
168
  const notes = [];
160
169
 
161
170
  for (const row of rows) {
162
- if (row.klass === 'terminal' || row.klass === 'record') {
171
+ if (authorisesRemoval(row.klass)) {
163
172
  problems.push(
164
173
  `${label}:${row.line}: a ${row.klass} row is still listed (${row.evidence}) — its story belongs to the ` +
165
174
  `ADR or the changelog, and the row leaves the queue in the same commit: ${row.title.slice(0, 80)}`,
@@ -0,0 +1,176 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { constants, openSync, closeSync, fstatSync, fsyncSync, readFileSync, writeFileSync, writeSync, unlinkSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { canonicalHeading } from './queue-audit.mjs';
5
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
6
+
7
+ export const EXIT = Object.freeze({ accept: 0, refuse: 1, usage: 2 });
8
+ const MAGIC = 'queue-purge: 1';
9
+ const HEADER_FIELDS = ['date', 'section', 'queue-path', 'section-bytes', 'index-bytes', 'section-sha256', 'block-sha256', 'rows'];
10
+ const BLOCK_DIGEST_FIELD = 'block-sha256';
11
+ const TITLE_WIDTH = 80;
12
+ const KEYLESS = '<keyless>';
13
+ const LF = 0x0a;
14
+ const HASH = 'sha256';
15
+ const HEX_DIGEST = /^[a-f0-9]{64}$/;
16
+ const COUNT = /^(0|[1-9][0-9]*)$/;
17
+ const INDEX_COLUMNS = 4;
18
+ const PRIVATE_MODE = 0o600;
19
+
20
+ export const hashBytes = (bytes) => createHash(HASH).update(bytes).digest();
21
+ export const renderTitle = (title) => String(title).replace(/\s+/g, ' ').trim().slice(0, TITLE_WIDTH);
22
+
23
+ export const sliceSection = (bytes, range) => {
24
+ const starts = [0];
25
+ bytes.forEach((byte, offset) => { if (byte === LF) starts.push(offset + 1); });
26
+ const start = starts[range.headingLine - 1];
27
+ const end = starts[range.to - 1] ?? bytes.length;
28
+ if (start === undefined || end < start) throw fail(EXIT.usage, 'invalid section byte range');
29
+ return bytes.subarray(start, end);
30
+ };
31
+
32
+ const renderHeader = (fields, includeDigest = true) => Buffer.from([
33
+ MAGIC, ...HEADER_FIELDS.filter((field) => includeDigest || field !== BLOCK_DIGEST_FIELD)
34
+ .map((field) => `${field}: ${fields[field]}`), '', '',
35
+ ].join('\n'));
36
+
37
+ const sealBlock = (headerBytes, body) => createHash(HASH).update(headerBytes).update(body).digest();
38
+
39
+ export const frameBlock = ({ date, section, queuePath, sectionBytes, indexEntries }) => {
40
+ const indexBytes = Buffer.from(indexEntries.map(({ line, klass, id, title }) =>
41
+ `${line}\t${klass}\t${id ?? KEYLESS}\t${renderTitle(title)}\n`).join(''));
42
+ const body = Buffer.concat([sectionBytes, indexBytes]);
43
+ const fields = {
44
+ date, section: JSON.stringify(canonicalHeading(section)), 'queue-path': JSON.stringify(queuePath),
45
+ 'section-bytes': sectionBytes.length, 'index-bytes': indexBytes.length,
46
+ 'section-sha256': hashBytes(sectionBytes).toString('hex'), rows: indexEntries.length,
47
+ };
48
+ fields[BLOCK_DIGEST_FIELD] = sealBlock(renderHeader(fields, false), body).toString('hex');
49
+ return Buffer.concat([renderHeader(fields), body]);
50
+ };
51
+
52
+ const readHeader = (bytes, offset) => {
53
+ const state = { at: offset, line: 1 };
54
+ const readLine = () => {
55
+ const end = bytes.indexOf(LF, state.at);
56
+ if (end < 0) throw { line: state.line, truncated: true };
57
+ const line = bytes.subarray(state.at, end).toString('utf8');
58
+ state.at = end + 1;
59
+ state.line += 1;
60
+ return line;
61
+ };
62
+ const opening = readLine();
63
+ if (opening !== MAGIC) throw { line: 1 };
64
+ const fields = {};
65
+ const sealedParts = [bytes.subarray(offset, state.at)];
66
+ for (const field of HEADER_FIELDS) {
67
+ const start = state.at;
68
+ const line = readLine();
69
+ const prefix = `${field}: `;
70
+ if (!line.startsWith(prefix)) throw { line: state.line - 1 };
71
+ fields[field] = line.slice(prefix.length);
72
+ if (field !== BLOCK_DIGEST_FIELD) sealedParts.push(bytes.subarray(start, state.at));
73
+ }
74
+ const separator = state.at;
75
+ if (readLine() !== '') throw { line: state.line - 1 };
76
+ sealedParts.push(bytes.subarray(separator, state.at));
77
+ for (const field of ['section-bytes', 'index-bytes', 'rows']) {
78
+ if (!COUNT.test(fields[field]) || !Number.isSafeInteger(Number(fields[field]))) {
79
+ throw { line: HEADER_FIELDS.indexOf(field) + 2 };
80
+ }
81
+ }
82
+ for (const field of ['section-sha256', BLOCK_DIGEST_FIELD]) {
83
+ if (!HEX_DIGEST.test(fields[field])) throw { line: HEADER_FIELDS.indexOf(field) + 2 };
84
+ }
85
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(fields.date)) throw { line: 2 };
86
+ const readString = (field) => {
87
+ try {
88
+ const value = JSON.parse(fields[field]);
89
+ if (typeof value === 'string') return value;
90
+ } catch { /* The field's line is reported below. */ }
91
+ throw { line: HEADER_FIELDS.indexOf(field) + 2 };
92
+ };
93
+ return { fields, bodyStart: state.at, sealedHeader: Buffer.concat(sealedParts),
94
+ section: readString('section'), queuePath: readString('queue-path') };
95
+ };
96
+
97
+ const readIndex = (bytes) => {
98
+ if (bytes.length === 0) return [];
99
+ if (bytes.at(-1) !== LF) return null;
100
+ const lines = bytes.toString('utf8').slice(0, -1).split('\n');
101
+ const entries = lines.map((text) => {
102
+ const cells = text.split('\t');
103
+ if (cells.length !== INDEX_COLUMNS) return null;
104
+ const [line, klass, key, title] = cells;
105
+ if (!COUNT.test(line) || Number(line) < 1 || !Number.isSafeInteger(Number(line)) || !klass || !key || /\r/.test(text)) return null;
106
+ return { line: Number(line), klass, id: key === KEYLESS ? null : key, title };
107
+ });
108
+ return entries.includes(null) ? null : entries;
109
+ };
110
+
111
+ const judgeBlock = (block) => Boolean(block.indexEntries
112
+ && block.rowCount === block.indexEntries.length
113
+ && hashBytes(block.sectionBytes).equals(block.sectionDigest)
114
+ && sealBlock(block.sealedHeader, block.body).equals(block.blockDigest));
115
+
116
+ export const readArchive = (bytes) => {
117
+ const archive = { bytes, blocks: [], intact: true, offset: 0, cause: null };
118
+ const refuse = (cause, line) => Object.assign(archive, {
119
+ intact: false, cause, line,
120
+ problem: cause === 'foreign' ? `foreign archive grammar at line ${line}`
121
+ : `archive not intact: block ${archive.blocks.length + 1} at byte offset ${archive.offset}; no key comparison performed`,
122
+ });
123
+ while (archive.offset < bytes.length) {
124
+ const offset = archive.offset;
125
+ const parsed = (() => {
126
+ try { return readHeader(bytes, offset); }
127
+ catch (error) {
128
+ const recognised = bytes.subarray(offset, offset + Buffer.byteLength(MAGIC)).equals(Buffer.from(MAGIC));
129
+ refuse(offset === 0 && !(recognised && error.truncated) ? 'foreign' : 'not-intact', error.line);
130
+ return null;
131
+ }
132
+ })();
133
+ if (!parsed) return archive;
134
+ const { fields, bodyStart, sealedHeader, section, queuePath } = parsed;
135
+ const sectionEnd = bodyStart + Number(fields['section-bytes']);
136
+ const end = sectionEnd + Number(fields['index-bytes']);
137
+ if (!Number.isSafeInteger(end) || end > bytes.length) return refuse('not-intact');
138
+ const block = {
139
+ date: fields.date, section, queuePath, offset, end, sealedHeader,
140
+ sectionBytes: bytes.subarray(bodyStart, sectionEnd), indexBytes: bytes.subarray(sectionEnd, end),
141
+ sectionDigest: Buffer.from(fields['section-sha256'], 'hex'),
142
+ blockDigest: Buffer.from(fields[BLOCK_DIGEST_FIELD], 'hex'),
143
+ rowCount: Number(fields.rows), body: bytes.subarray(bodyStart, end),
144
+ indexEntries: readIndex(bytes.subarray(sectionEnd, end)),
145
+ };
146
+ if (!judgeBlock(block)) return refuse('not-intact');
147
+ archive.blocks.push(block);
148
+ archive.offset = end;
149
+ }
150
+ return archive;
151
+ };
152
+
153
+ export const judgeIntact = (archive) => archive.intact && archive.offset === archive.bytes.length
154
+ && archive.blocks.every(judgeBlock);
155
+
156
+ export const appendBlock = (path, blockBytes) => {
157
+ const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
158
+ const staged = openSync(temporary, 'wx', PRIVATE_MODE);
159
+ try {
160
+ writeFileSync(staged, blockBytes);
161
+ fsyncSync(staged);
162
+ const complete = readFileSync(temporary);
163
+ if (!complete.equals(blockBytes)) throw fail(EXIT.refuse, `incomplete staged block for ${path}`);
164
+ const descriptor = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND
165
+ | constants.O_NOFOLLOW | constants.O_NONBLOCK, PRIVATE_MODE);
166
+ try {
167
+ if (!fstatSync(descriptor).isFile()) throw fail(EXIT.usage, `${path}: archive is not a regular file`);
168
+ const written = writeSync(descriptor, complete);
169
+ fsyncSync(descriptor);
170
+ if (written !== complete.length) throw fail(EXIT.refuse, `${path}: partial archive append (${written} of ${complete.length} bytes)`);
171
+ } finally { closeSync(descriptor); }
172
+ } finally {
173
+ try { closeSync(staged); }
174
+ finally { unlinkSync(temporary); }
175
+ }
176
+ };
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ import { constants, openSync, closeSync, fstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
5
+ import { isDirectRun } from './direct-run.mjs';
6
+ import { EXIT, appendBlock, readArchive } from './queue-purge-archive.mjs';
7
+ import { snapshotQueue, checkPurge } from './queue-purge.mjs';
8
+
9
+ const VERBS = ['snapshot', '--check'];
10
+ const OPTIONS = new Map([['--section', 'section'], ['--archive', 'archive']]);
11
+ const HELP_FLAGS = ['--help', '-h'];
12
+ const HELP = `Usage:
13
+ node queue-purge-cli.mjs snapshot <queue-file> --section <heading> --archive <file>
14
+ node queue-purge-cli.mjs --check <queue-file> --section <heading> --archive <file>
15
+
16
+ snapshot appends the section bytes and row index; --check verifies the purge without writing.
17
+ Exit codes: 0 accept, 1 refuse, 2 usage. --help or -h must be the whole invocation.`;
18
+
19
+ const parseArgv = (argv) => {
20
+ if (argv.length === 1 && HELP_FLAGS.includes(argv[0])) return { help: true };
21
+ const options = {};
22
+ const state = { index: 0 };
23
+ const readValue = (flag) => {
24
+ const value = argv[state.index + 1];
25
+ if (!value || value.startsWith('-') || VERBS.includes(value)) throw fail(EXIT.usage, `${flag} takes a value`);
26
+ state.index += 1;
27
+ return value;
28
+ };
29
+ while (state.index < argv.length) {
30
+ const arg = argv[state.index];
31
+ if (VERBS.includes(arg)) {
32
+ if (options.verb) throw fail(EXIT.usage, 'name exactly one verb: snapshot or --check');
33
+ options.verb = arg;
34
+ options.queuePath = readValue(arg);
35
+ } else if (OPTIONS.has(arg)) {
36
+ const field = OPTIONS.get(arg);
37
+ if (Object.hasOwn(options, field)) throw fail(EXIT.usage, `${arg} was given twice`);
38
+ options[field] = readValue(arg);
39
+ } else if (HELP_FLAGS.includes(arg)) throw fail(EXIT.usage, '--help is answered only as the whole invocation');
40
+ else throw fail(EXIT.usage, `unknown argument ${JSON.stringify(arg)}`);
41
+ state.index += 1;
42
+ }
43
+ if (!options.verb) throw fail(EXIT.usage, 'exactly one verb is required: snapshot or --check');
44
+ for (const [flag, field] of OPTIONS) if (!options[field]) throw fail(EXIT.usage, `${flag} is required`);
45
+ return options;
46
+ };
47
+
48
+ const readRegular = (path, read) => {
49
+ const descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
50
+ try {
51
+ const stats = fstatSync(descriptor);
52
+ if (!stats.isFile()) throw fail(EXIT.usage, `${path}: not a regular file`);
53
+ return read(descriptor, stats);
54
+ } finally { closeSync(descriptor); }
55
+ };
56
+
57
+ const readFiles = ({ queuePath, archive, verb }) => {
58
+ const queueReal = realpathSync(queuePath);
59
+ const queue = readRegular(queueReal, (descriptor, stats) => ({ bytes: readFileSync(descriptor), stats }));
60
+ if (!statSync(dirname(archive)).isDirectory()) throw fail(EXIT.usage, `${archive}: parent is not a directory`);
61
+ const archiveReal = (() => {
62
+ try { return realpathSync(archive); }
63
+ catch (error) { if (error.code === 'ENOENT') return null; throw error; }
64
+ })();
65
+ if (resolve(queuePath) === resolve(archive) || queueReal === archiveReal) {
66
+ throw fail(EXIT.usage, `${archive}: archive resolves to the queue ${queuePath}`);
67
+ }
68
+ const archiveBytes = (() => {
69
+ try {
70
+ return readRegular(archive, (descriptor, stats) => {
71
+ if (stats.dev === queue.stats.dev && stats.ino === queue.stats.ino) {
72
+ throw fail(EXIT.usage, `${archive}: archive is a hard link to the queue ${queuePath}`);
73
+ }
74
+ return readFileSync(descriptor);
75
+ });
76
+ } catch (error) {
77
+ if (error.code === 'ENOENT' && verb === 'snapshot') return Buffer.alloc(0);
78
+ throw error;
79
+ }
80
+ })();
81
+ if (!archiveBytes.length && verb === '--check') throw fail(EXIT.usage, `${archive}: empty archive`);
82
+ return { queueBytes: queue.bytes, archiveBytes };
83
+ };
84
+
85
+ export const main = (argv, { log = console.log, error = console.error } = {}) => {
86
+ const state = { fallback: EXIT.usage, options: null };
87
+ try {
88
+ const options = parseArgv(argv);
89
+ state.options = options;
90
+ if (options.help) { log(HELP); return EXIT.accept; }
91
+ const inputs = readFiles(options);
92
+ const args = { ...options, ...inputs, date: new Date().toISOString().split('T')[0] };
93
+ const result = options.verb === 'snapshot' ? snapshotQueue(args) : checkPurge(args);
94
+ for (const problem of result.problems) error(problem);
95
+ if (!result.ok) return EXIT.refuse;
96
+ if (options.verb === 'snapshot') {
97
+ state.fallback = EXIT.refuse;
98
+ appendBlock(options.archive, result.block);
99
+ const [block] = readArchive(result.block).blocks;
100
+ for (const note of result.notes) error(note);
101
+ log(`${options.archive}: ${block.rowCount} rows, ${block.sectionBytes.length} section bytes (${block.section})`);
102
+ } else log(`accept: ${options.section}; archive ${options.archive}`);
103
+ return EXIT.accept;
104
+ } catch (err) {
105
+ const paths = state.options ? `${state.options.queuePath}; archive ${state.options.archive}: ` : '';
106
+ error(`${paths}${err.message}`);
107
+ return [EXIT.refuse, EXIT.usage].includes(err.exitCode) ? err.exitCode : state.fallback;
108
+ }
109
+ };
110
+
111
+ if (isDirectRun(import.meta.url)) process.exitCode = main(process.argv.slice(2));
@@ -0,0 +1,66 @@
1
+ import { auditQueue, authorisesRemoval, canonicalHeading } from './queue-audit.mjs';
2
+ import { EXIT, frameBlock, hashBytes, judgeIntact, readArchive, renderTitle, sliceSection } from './queue-purge-archive.mjs';
3
+ import { fail } from '../references/scripts/markdown-blocks.mjs';
4
+
5
+ const readInputs = ({ queueBytes, section, archiveBytes = Buffer.alloc(0) }) => {
6
+ if (!canonicalHeading(section)) throw fail(EXIT.usage, '--section is required');
7
+ const audit = auditQueue(queueBytes.toString('utf8'), { range: section });
8
+ const rows = audit.rows.filter(({ line }) => line >= audit.range.from && line < audit.range.to);
9
+ const problems = [];
10
+ const keys = new Map();
11
+ for (const row of audit.rows) {
12
+ if (!row.id) continue;
13
+ const earlier = keys.get(row.id);
14
+ if (earlier) problems.push(`key defect: doubled id ${row.id} at file lines ${earlier.line} and ${row.line}`);
15
+ else keys.set(row.id, row);
16
+ }
17
+ const archive = readArchive(archiveBytes);
18
+ if (archive.cause === 'foreign') throw fail(EXIT.usage, archive.problem);
19
+ return { rows, problems, archive, section: canonicalHeading(section), sectionBytes: sliceSection(queueBytes, audit.range) };
20
+ };
21
+
22
+ const selectBlock = (archive, section) => archive.blocks.findLast((block) => canonicalHeading(block.section) === section);
23
+ const makeResult = (problems, notes = [], block) => ({ ok: problems.length === 0, problems, notes,
24
+ ...(block === undefined ? {} : { block }) });
25
+
26
+ export const snapshotQueue = (options) => {
27
+ const { rows, problems, archive, section, sectionBytes } = readInputs(options);
28
+ const keyless = rows.filter(({ id }) => !id);
29
+ const notes = keyless.length ? [`${keyless.length} keyless rows archived in ${section}`] : [];
30
+ if (!judgeIntact(archive)) problems.push(archive.problem);
31
+ if (problems.length) return makeResult(problems, [], null);
32
+ const previous = selectBlock(archive, section);
33
+ if (previous?.sectionDigest.equals(hashBytes(sectionBytes))) {
34
+ return makeResult([`same section digest as the last block for ${section}`], [], null);
35
+ }
36
+ const block = frameBlock({ date: options.date, section, queuePath: options.queuePath, sectionBytes, indexEntries: rows });
37
+ return makeResult(problems, notes, block);
38
+ };
39
+
40
+ export const checkPurge = (options) => {
41
+ const { rows, problems, archive, section, sectionBytes } = readInputs(options);
42
+ for (const row of rows.filter(({ id }) => !id)) {
43
+ problems.push(`key defect: no id at current file line ${row.line}: ${renderTitle(row.title)}`);
44
+ }
45
+ if (!judgeIntact(archive)) return makeResult([...problems, archive.problem]);
46
+ const block = selectBlock(archive, section);
47
+ if (!block) {
48
+ const recorded = [...new Set(archive.blocks.map(({ section: heading }) => heading))];
49
+ throw fail(EXIT.usage, `no archive block for ${section}; recorded headings: ${recorded.join(', ') || '(none)'}`);
50
+ }
51
+ if (block.sectionDigest.equals(hashBytes(sectionBytes))) problems.push(`section not moved: ${section}`);
52
+ const recordedKeys = new Set(block.indexEntries.filter(({ id }) => id).map(({ id }) => id));
53
+ const currentKeys = new Set(rows.filter(({ id }) => id).map(({ id }) => id));
54
+ for (const row of rows) {
55
+ if (row.id && !recordedKeys.has(row.id)) {
56
+ problems.push(`addition: ${row.id} at current file line ${row.line}: ${renderTitle(row.title)}`);
57
+ }
58
+ }
59
+ for (const entry of block.indexEntries) {
60
+ if (!entry.id) problems.push(`keyless entry at archived line ${entry.line}: ${entry.title}`);
61
+ else if (!currentKeys.has(entry.id) && !authorisesRemoval(entry.klass)) {
62
+ problems.push(`unauthorised absence: ${entry.id} at archived line ${entry.line}: ${entry.title} (recorded class ${entry.klass})`);
63
+ }
64
+ }
65
+ return makeResult(problems);
66
+ };