From a60ef92ba3c26d2dfca1099d4e64259d2f4e3f9f Mon Sep 17 00:00:00 2001 From: BradleyDB <23158057+BradleyDB@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:46:55 -0700 Subject: [PATCH 1/5] describe loop and its fences: within-run abort, actionKey read gate, quoted {page} (DB-1..DB-4; F-456, F-458, #10, #13; plugin 0.39.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB-1 (#10): every capture.mjs --paginate fence quotes its --out value — Windows PowerShell 5.1 consumes an unquoted {page} (measured: argv arrives as `…-`). The setup canon states why beside the fence; capture's refusal for a missing placeholder names the shell cause; check-doc-drift check 14 holds the quoting (mutant 14j). Closes #10. DB-2 (F-456): the describe-shape read predicate is one exported doc-lib function, isDescribeRead(verb, cmd) — a describe-shaped catalog actionKey admits on its own merits, the trailing word admits as before, a trimmed catalog decides on the word. describe-batch and capture both compose it: `cn chain` (describe-job-chain) and the unlisted sibling `re r execution` (describe-execution) are describable and capturable. A committed sweep over the shipped catalog pins the admitted set (25 at 1.0.9). DB-3 (#13) + DB-4 (F-458): describe-batch aborts a domain after five consecutive failed marks (marks stand), and aborts on the FIRST failed spawn carrying the CLI's re-login sentence (doc-lib isAuthDeath, version-stamped as a stale-facts tripwire) with nothing marked — the in-flight entry keeps its status, a designer composite keeps its item pending. One additive summary field, aborted: { reason: "consecutive-failures" | "auth", after, lastError }; budgetExhausted / moreRemaining / failures unchanged (contract). Setup Phase 5 names the within-run limit and both reasons, and sizes batches against the nearer deadline and the slow end of observed rates. Closes #13. Rulings (Bradley, 2026-09-14): first-sighting auth abort with no mark; one aborted object with a reason enum; failed spawn AND phrase; all four re-login literals via the shared sentence; the limit a constant of 5. Review round (medium): 8 findings fixed — the auth prose overstated "nothing marked"; domainProgress re-read at an abort; the designer per-entry abort counter dropped; the designer-lane reference and MAINTAINERS' row updated; a header splice; the auth classifier moved to doc-lib; the fake CLI's controls refuse to run without their counter. Bus: F-456 and F-458 FIXED with Fix/Class/Judge/Sibling sweep lines; the two live arms banked in dev/VALIDATION.md for Session B-V. --- build/check-doc-drift.mjs | 11 + build/check-stale-facts.mjs | 5 + build/test-check-doc-drift.mjs | 18 ++ build/test-check-stale-facts.mjs | 4 +- dev/FEEDBACK.md | 12 +- dev/VALIDATION.md | 57 ++++ .../gs-superadmin/.claude-plugin/plugin.json | 2 +- plugins/gs-superadmin/CHANGELOG.md | 27 ++ plugins/gs-superadmin/MAINTAINERS.md | 4 +- plugins/gs-superadmin/scripts/capture.mjs | 22 +- .../gs-superadmin/scripts/describe-batch.mjs | 145 ++++++++-- plugins/gs-superadmin/scripts/doc-lib.mjs | 61 ++++- plugins/gs-superadmin/skills/audit/SKILL.md | 6 +- .../gs-superadmin/skills/deprecate/SKILL.md | 6 +- .../skills/email-report/SKILL.md | 2 +- plugins/gs-superadmin/skills/refresh/SKILL.md | 2 +- plugins/gs-superadmin/skills/setup/SKILL.md | 27 +- .../setup/references/document-domain-notes.md | 5 +- .../setup/references/document-mechanics.md | 4 + plugins/gs-superadmin/test/capture.mjs | 11 + plugins/gs-superadmin/test/describe-batch.mjs | 258 +++++++++++++++++- .../gs-superadmin/test/doc-lib-fixtures.mjs | 34 ++- 22 files changed, 670 insertions(+), 53 deletions(-) diff --git a/build/check-doc-drift.mjs b/build/check-doc-drift.mjs index eee04f7..a263a22 100644 --- a/build/check-doc-drift.mjs +++ b/build/check-doc-drift.mjs @@ -1674,6 +1674,17 @@ for (const name of skills) { lines.forEach((l, i) => { for (const t of helperCodeTexts(l, inFence(i))) { if (!t.includes("capture.mjs") || !t.includes("--paginate")) continue; + // Issue #10 (rung 5 — copies held to a shape): an --out carrying the + // {page} placeholder must be QUOTED. Windows PowerShell consumes the + // braces of an unquoted value (measured 2026-09-14: argv arrives as + // `…-`), and the shipped fences are the only home of that fact. + const outVal = /--out\s+(\S+)/.exec(t)?.[1] ?? ""; + if (outVal.includes("{page}") && !/^(['"]).*\1$/.test(outVal)) { + fail( + `${rel}:${i + 1}: paginate fence's --out carries {page} unquoted — Windows PowerShell consumes the braces; ` + + `quote the value ('-{page}.json') (issue #10)`, + ); + } const cmd = t.split(/\s--\s/)[1] ?? ""; for (const [needle, path] of Object.entries(PAGINATE_STATED_PATHS)) { if (new RegExp("(^|\\s)" + needle.replace(/ /g, "\\s+") + "(\\s|$)").test(cmd) && !t.includes(`--items-path ${path}`)) { diff --git a/build/check-stale-facts.mjs b/build/check-stale-facts.mjs index 96bfda1..92b9e08 100644 --- a/build/check-stale-facts.mjs +++ b/build/check-stale-facts.mjs @@ -520,6 +520,11 @@ const FACT_CARRIERS = [ // The read-verb allowlist is re-audited by hand at each adoption; pinning // the audit note's version stamp forces that re-audit when the pin moves. { re: /read actions in the v(\d+\.\d+\.\d+) catalog/, expect: meta.cliVersion, what: "read-verb allowlist audit version" }, + // The auth-death sentence the spawn-capable scripts classify a token + // death by (F-458) is the CLI's own re-login instruction, read from the + // installed package's auth module; the version stamp beside it forces + // a re-read of that file when the pin moves. + { re: /\/\/ v(\d+\.\d+\.\d+) package's dist\/core\/auth\/index\.js/, expect: meta.cliVersion, what: "auth-death literal audit version" }, ], }, ]; diff --git a/build/test-check-doc-drift.mjs b/build/test-check-doc-drift.mjs index 69c70e6..b7b0f7b 100644 --- a/build/test-check-doc-drift.mjs +++ b/build/test-check-doc-drift.mjs @@ -1110,6 +1110,24 @@ try { res.status === 1 && /rp list.*does not state its rows path/.test(res.stderr) && /--items-path data\.data/.test(res.stderr), res.stderr); } finally { snap.restore(); } } + // (j) issue #10: a paginate fence whose --out carries {page} UNQUOTED must + // go red — Windows PowerShell consumes the braces, and nothing but the + // fence carries the quoting. Mutant: strip the quotes from the audit rules + // fence. + { + const auditRel = "plugins/gs-superadmin/skills/audit/SKILL.md"; + const snap = snapshotFiles([at(auditRel)]); + try { + mutate(auditRel, (s) => { + const q = "--out '.gs-superadmin/tmp/audit-rules-{page}.json'"; + if (!s.includes(q)) throw new Error("audit rules fence no longer carries the quoted {page} --out — fixture stale"); + return s.replace(q, "--out .gs-superadmin/tmp/audit-rules-{page}.json"); + }); + const res = run(); + check("check 14j: a paginate fence with an UNQUOTED {page} --out goes red (issue #10)", + res.status === 1 && /audit\/SKILL\.md:\d+: paginate fence's --out carries \{page\} unquoted/.test(res.stderr), res.stderr); + } finally { snap.restore(); } + } } // ── check 10i (F-394): the BARE placeholder spelling inside a fence ──────── diff --git a/build/test-check-stale-facts.mjs b/build/test-check-stale-facts.mjs index 3ad6de5..3c3389d 100644 --- a/build/test-check-stale-facts.mjs +++ b/build/test-check-stale-facts.mjs @@ -81,9 +81,11 @@ function runRig(files) { ); // All three F-280 carriers live in doc-lib since the shared-gate hoist // (B5 W4/DS-17 + its review round). + // …plus the auth-death literal's audit stamp (F-458), same home. writeFileSync( join(rig, "plugins", "gs-superadmin", "scripts", "doc-lib.mjs"), - `// strict writers went 29 → 0; POST reads 59 → 0\n// read actions in the v${PIN} catalog audited\n`, + `// strict writers went 29 → 0; POST reads 59 → 0\n// read actions in the v${PIN} catalog audited\n` + + `// the sentence every auth-path throw in the\n// v${PIN} package's dist/core/auth/index.js ends with\n`, ); // Coverage floor (F-101): the checker requires ≥40 tracked .md files with // some nested — filler carries no version/count/flag tokens. diff --git a/dev/FEEDBACK.md b/dev/FEEDBACK.md index f1415dc..5081f76 100644 --- a/dev/FEEDBACK.md +++ b/dev/FEEDBACK.md @@ -304,7 +304,7 @@ Note the same conflation is deliberate and correct elsewhere: Phase 6's precondi Second, smaller point found while measuring this: the manifest is rewritten continuously during a run, so a tally taken mid-ingest is a snapshot of a moving target and will disagree with one taken a minute later. A completeness check is only meaningful at a quiescent point. (The reporter made exactly this mistake while drafting the entry — a mid-run sample showed one asset short of a domain's full ingest, which the next read showed complete.) Expected: depth is part of the mechanical account, not something a reader reconstructs. `report` (or a sibling verb) carries a per-domain full/stub/list-only breakdown derived from the `depth` the manifest already stores, with list-only domains reported complete by definition rather than as stubs, so the three states stay distinguishable. And every place a skill states that documentation is complete, or that a domain is fully ingested, derives that statement from the script's output rather than from its own narrative — one invocation, quoted, at a point where nothing is still writing. -## F-456 — OPEN +## F-456 — FIXED Reported: 2026-09-10 (tester — first live multi-tenant setup, deep-ingesting a connectors domain) Severity: normal — a catalog-declared read is refused, costing a manual fallback per affected domain; nothing is corrupted and the refusal is loud, but it is discoverable only by hitting it mid-run What: `describe-batch.mjs`'s read-verb gate decides whether a describe command is a safe per-item read by matching the **command path's trailing word** against `READ_VERB_EXACT` in `doc-lib.mjs` (plus a describe-shape regex). The trailing word is an arbitrary noun, so a legitimate per-item read whose path ends in something unlisted is refused. Live: `cn chain` was refused and the domain had to be documented through the manual per-asset path instead. The catalog already carries two fields that both say it is safe: @@ -319,6 +319,10 @@ Provenance — why this survived four CLI adoptions: it is not an upstream-watch Expected: the gate admits a per-item read on evidence the catalog already carries, without depending on a hand-maintained list of path-trailing nouns and without trusting `mutating`. Any command the gate would refuse should be enumerable from the catalog ahead of time rather than discovered by a run failing — a one-off sweep of every recorded `describeCommand` against the gate would say today which domains are affected. Amended 2026-09-10 (tester, same round) — two corrections from measuring the gate rather than reading it. (1) The fix is SMALLER than the Expected above implies. The predicate is `(n) => /^describe(-|$)/.test(n) || READ_VERB_EXACT.has(n)`, and that regex already admits `describe-job-chain` on its own. The matching logic is correct as written; the defect is entirely in what is handed to it — the command path's trailing word rather than the actionKey. The change is an argument, not new matching, and the hand-maintained set stops being load-bearing as a side effect rather than needing removal. (2) Blast radius measured rather than assumed. Sweeping every recorded `describeCommand` in a live production workspace against that predicate refuses exactly ONE of eighteen domains — the connectors-chains lane, `gs-admin --json cn chain --id {id}` — while eleven pass (`describe`, `describe-external-action`, `template`, `get`, `measures`) and six are recorded list-only, where stubs are complete docs and no describe runs. So at this pin the gate costs one manual fallback per affected workspace, not a class-wide unknown, and the severity above is right. That sweep is exactly the baseline check this entry's Expected asks for; it reads only the manifest's recorded commands and the predicate, so it belongs as a test over the catalog rather than as a one-off — which would also have named `cn chain` before anyone ran into it. Amended again 2026-09-10 (tester, same round) — the gate is not only a nuisance, it leaves NO LEGAL ROUTE. The operating model instructs that captures never use a bare shell redirect, and `capture.mjs` refuses a gate-rejected read for the same reason `describe-batch.mjs` does. For a command the gate refuses, both sanctioned paths are therefore closed, and the only way to document the domain is the redirect the docs prohibit — which is what this round did for the connectors-chains lane, followed by `--normalize`. The skill offers `--normalize` as recovery from an ACCIDENTAL raw capture, not as a sanctioned route for a read its own gate rejects, so following the documented rules to the letter leaves the domain undocumentable. That makes the fix above load-bearing rather than cosmetic: closing the gate's blind spot removes the contradiction. If the gate is left as-is instead, the docs owe a sanctioned fallback naming redirect-then-`--normalize` for gate-refused reads, because today they forbid the only thing that works. +Fix: 2026-09-14 (builder, Session B / DB-2) — the read-shape predicate both spawn-capable scripts compose on is now ONE function in doc-lib, `isDescribeRead(verb, cmd)`: a describe-shaped `actionKey` (`describe` or `describe-…`, matched as a whole segment) admits on its own merits; the resolved path's trailing word admits on the describe shape or READ_VERB_EXACT exactly as before; a hand-trimmed catalog with no actionKey decides on the word alone. Measured before choosing the shape: the plan's literal reading (actionKey INSTEAD of the word) would have refused the twelve allowlisted reads whose keys are get-/list-/topic-shaped (`jo e template` is get-email-template, `sc measures` get-scorecard-measures), so the key is a second admit, not a replacement. describe-batch's policy IS that function; capture composes it under its list/`check` clauses and inherits the admit — both sanctioned routes now document the connectors-chains lane, so the third note's contradiction closes. `mutating` is still never consulted; the endpoint gate stays independent (a describe-shaped key over a PUT is pinned refused). Class named: one rule with two consumers, both wrong the same way. Fixtures (pre-fix red, post-fix green): test/describe-batch.mjs — `cn chain --id {id}` admitted, the trimmed twin refused on the word, the PUT twin refused by the endpoint gate, nothing marked by a refusal; test/capture.mjs — the same pair through the capture gate; test/doc-lib-fixtures.mjs — 16 arms over the predicate's decision points. The sweep the Expected asks for is a committed test, not a one-off: every describe-* actionKey in the SHIPPED catalog through the predicate (25 commands admitted at the pin, pinned as data so a pin that changes the set is named by the suite), plus the safety direction (nothing admitted is mutating or declares PUT/DELETE/PATCH). Sibling the tester did not list, found by that sweep: `re r execution` (describe-execution, the per-item read of a rule execution) was refused by the same trailing-word test and is admitted now. MAINTAINERS.md's capture row follows. Version 0.39.0 (rides the DB-3/DB-4 behaviour change). Pick up: /reload-plugins. Review round (medium, 8 finders + 3 file-batched verifiers): DESCRIBE_SHAPE_RE exported so the catalog sweep tests the gate's own shape, never a re-spelled copy; the hand count of allowlisted reads dropped from the doc-lib comment (a count no check can hold). +Class: consumer-parity +Judge: (1) the catalog sweep in test/describe-batch.mjs over the shipped catalog — enumeration from the ARTIFACT (every describe-* actionKey), never from the arms; (2) independent of the fixer: the tester's live deep-ingest of the connectors-chains lane through describe-batch.mjs with the RECORDED describeCommand and no --command, no manual per-asset fallback, no --normalize — banked in dev/VALIDATION.md (F-456 section, Session B): one chain's doc lands under the domain folder with doc_path recorded and the summary reads commandSource recorded, documented ≥ 1; the capture helper admits one chain the same way. A gate refusal on either route REOPENS this entry. +Sibling sweep: consumer-parity recipe — `git grep -n 'READ_VERB_EXACT\|isDescribeRead\|isCaptureRead\|isReadVerb' -- plugins build` (tests excluded): three homes, all on the shared function — doc-lib.mjs (the definition), describe-batch.mjs (its policy IS the function), capture.mjs (composes it under its list/`check` clauses); domain-candidates.mjs reads actionKey for LIST shapes only and carries no describe policy; the guard hook decides on `mutating` plus ask-overrides and never on a verb, so it is not a consumer; MAINTAINERS.md's capture row was the one prose copy and follows in the same change. `node build/sweep-twins.mjs` after the change: no twin between the two scripts' gate blocks; the one NEW twin it raised was inside describe-batch itself (the two full-depth documented marks, lengthened by the reset line) — hoisted into one `markDocumented` in the same change. Cost: seconds. ## F-457 — OPEN Reported: 2026-09-10 (tester — first live multi-tenant setup, across four `--deep` runs) @@ -329,10 +333,14 @@ What: two expensive operations do not state their own boundaries, so a session i The ingests still have value; it lands in `deps-report`, not the maps. Nothing says so. Expected: `--deep` states whether it re-indexes or goes straight to Phase 5. Phase 6 states that domains outside the five lanes do not affect the maps, and names where their deep ingest does pay off — so "deep-ingest X to improve the maps" stops being an inference a reader has to make. -## F-458 — OPEN +## F-458 — FIXED Reported: 2026-09-10 (tester — first live multi-tenant setup, during a large deep ingest) Severity: normal — writes durable wrong state into the manifest; recoverable on retry, but indistinguishable from real failure while it stands What: when a batch runs past a deadline, the assets it was mid-way through are marked `failed` in the manifest. This round oversized one batch and 17 healthy assets were recorded as failed, with the auth error as their recorded reason. All 17 described fine on retry. Nothing distinguishes them from a genuine describe failure: same status, same shape, and `report` counts them the same way. That is the durable half of this finding, and it is INDEPENDENT of which deadline was hit. A token stopping at half its lifetime is today's binding deadline, but the harness shell timeout is another and a full-lifetime token is a third — if the half-life defect is fixed upstream tomorrow the deadline simply moves, and this behaviour is unchanged. `failed` should mean "the CLI could not describe this asset", not "the run ended while this asset was in flight". The contributing half is sizing guidance. The batch-sizing advice is framed entirely around the harness shell timeout and never mentions a token deadline, and it offers no warning that a sample-derived rate under-predicts at scale. Measured this round: 2.36 s/asset over 25 assets, 2.77 s over 275, 3.09 s over 311 — a rate sampled small under-predicts a batch ten times its size by 15 to 30 percent. Sizing the 275-asset batch from the 25-asset sample is what overran the deadline. Every later batch was sized against the slow end with headroom: zero further failures across 3,700+ assets. Expected: a deadline-ended batch records that fact distinctly from a describe failure — the existing budget-exhaustion path already does exactly this (the entry keeps its status, the summary reports budgetExhausted) and a deadline death should join it rather than writing `failed`. Separately, sizing guidance names the binding deadline as whichever is nearest (shell timeout or token life, not just the former) and says to size against the slow end of observed rates, because a small sample under-predicts a large batch. +Fix: 2026-09-14 (builder, Session B / DB-3 + DB-4, one change together with issue #13) — the loop no longer writes a per-asset outcome from a signal that describes the session. Two within-run stops, ONE additive summary field `aborted: { reason, after, lastError }` — absent on a run that did not stop early; `budgetExhausted`, `moreRemaining` and `failures` unchanged in shape and meaning (the setup skill branches on them; contract). Reason `auth`: a FAILED spawn (non-zero exit or spawn error — the CLI's shared handler, dist/commands/base.js BaseCommand.catch, writes `Error: ` to stderr and exits 1 for every thrown error, measured at 1.0.9) whose stderr or stdout carries the CLI's own re-login instruction, `Run gs-admin login to (re-)authenticate` — the sentence all four auth-path throws in dist/core/auth/index.js end with (no stored token; expired with no refresh token; this entry's `Token expired and silent refresh failed`; `Token refresh failed ()`) — aborts on the FIRST sighting with NOTHING marked: the in-flight entry keeps its status like the budget-exhaustion path; a designer entry cut mid-drilldown keeps its composite with the item still pending (never `failed`); the next invocation resumes exactly as after a budget cut. Reason `consecutive-failures`: five entries in a row ending in a `failed` mark (every markFailed class; a designer entry counts once whatever its drilldown count; a documented or skipped mark resets; permanent designer gaps never mark and never count; budget exhaustion is not a failure) stops the loop with the five marks standing (issue #13). `after` = entries attempted this run, the aborting one included; stderr says `ABORTED after N entries ()`; exit stays 0. Rulings (Bradley, 2026-09-14): abort on first sighting with no mark; one object with a reason enum; failed-spawn AND phrase (a successful describe is never reclassified by its stderr — pinned by the exit-0-with-phrase arm); all four literals via the shared sentence; the limit a constant of 5, not a flag. The literal's version stamp is a check-stale-facts FACT_CARRIERS tripwire naming the CLI file. Prose: setup Phase 5's stop rule names the within-run limit and both `aborted` reasons; the batch-sizing sentence names the nearer of the two deadlines (shell timeout or the token's usable life from the Phase 1 pre-flight) and says to size against the slow end of observed rates, with this entry's three measured rates; document-mechanics §3's manual failure mark carries the same auth exception. Fixtures (pre-fix red, post-fix green; spawns counted from the fake CLI's own log, never from the summary): every-describe-fails stops at exactly 5 spawns of 8 with `after: 5`; F F F F S F F F runs all 8 (the reset); F F F F F S stops at 5 (the 6th never spawns); the resume after an abort documents all 8; auth after 2 successes — 3 spawns, `after: 3`, failures empty, the third entry still pending, domainProgress 2 of 5; the three sibling literals each abort on the first spawn with nothing marked; 4 real failures then auth reads `auth`, not `consecutive-failures`; the phrase under exit 0 is a success; designer: five failed drilldowns are ONE failed entry (no abort), an auth death on the third spawn leaves the entry unmarked with its field pending and the next run resumes it; the W9 budget arm now also asserts no `aborted`. Pre-fix, the designer auth arm reproduced this entry exactly — the entry marked `failed` with the re-login literal as its error. Version 0.39.0. Pick up: /reload-plugins. Review round (medium, 8 finders + 3 file-batched verifiers, 8 findings fixed): the auth prose overstated "nothing marked" — only the in-flight entry is unmarked, earlier real failures stand (setup Phase 5 + CHANGELOG corrected); domainProgress is re-read at an abort (an --upgrade abort between progress ticks read one failure short — new arm); the designer per-entry abort counter was dropped (the top-level field is the one record; the template-spawn death gained an arm); the designer-lane reference (document-domain-notes) names `aborted` beside its budget rule; MAINTAINERS' describe-batch row carries the contract; the auth classifier moved to doc-lib as one exported home for both spawn-capable scripts with the one FACT_CARRIERS tripwire; the fake CLI's fail/auth controls refuse to run without their spawn counter (they no-opped silently); and the {page} quoting is held by check-doc-drift check 14 (mutant 14j) with capture's refusal naming the shell cause. Dismissed with reasons: attempted-vs-processed (the progress line counts marks by design), the counter's three writers (every markFailed site continues — verified), the stdout scan and the sentence-vs-state-probe (both ruled 2026-09-14). +Class: outcome-from-proxy +Judge: the tester's live arm, banked in dev/VALIDATION.md (F-458 / #13 section, Session B): one describe-batch invocation sized to run PAST the token half-life from a terminal (the token, not the harness, must be the binding deadline); the summary must read aborted.reason auth with failures empty, the manifest must show every entry the run reached documented or at its prior status with ZERO entries failed under an auth error, and the next invocation after gs-admin login must resume — independent of the fixer's fake CLI, which only models the CLI's handler as read at 1.0.9. Any failed mark carrying the re-login sentence REOPENS this entry. +Sibling sweep: outcome-from-proxy recipe — for every field that states an outcome, the signal it is derived from and the unit that signal describes. describe-batch.mjs writes `failed` from five sites: a missing {name} (unit: the entry — stays), a failed top-level spawn (unit: the spawn — the session-wide auth class is now split out BEFORE the mark), the two renderer refusals (unit: the payload — stay), a designer entry's drilldown failures (unit: the drilldowns — the composite's per-item `failed` statuses were written from the same spawn signal and are split out the same way; the item stays pending). The manual path, document-mechanics §3's `mark --status failed` (unit: whatever the hand saw — prose is the only rung a hand step has; it now carries the exception). capture.mjs writes no outcome to the manifest (its verdict describes the capture itself). The guard's journal (F-428) already qualifies its word. Cost: one read per site, minutes. diff --git a/dev/VALIDATION.md b/dev/VALIDATION.md index 117677d..b683ffb 100644 --- a/dev/VALIDATION.md +++ b/dev/VALIDATION.md @@ -89,3 +89,60 @@ premise (every dataset is a dm object) does not hold on today's sandbox — reco numbers on the bus and do not exclude. Relationship maps are unaffected (the domain is not one of the five lanes); note in the verdict whether `deps-report` on the sandbox still answers for one of the 69 names through data-management. + +## F-456 — live deep-ingest of the connectors-chains lane through the RECORDED describe, no manual fallback (banked 2026-09-14, builder, Session B) + +Owed by: the tester round on the token the Session B handoff mints (see the Under test line). +Tenant: either; the sandbox is preferred (fewer chains). Reads only against the tenant; the +writes are to the local workspace manifest and the domain's KB folder. +Precondition: the workspace's connectors-chains domain records `describeCommand` as +`gs-admin --json cn chain --id {id}` (the lane the finding measured) — confirm with a local +read of `/_manifest.json` `domains_indexed`. If the July fallback left it recorded as +`none`, re-record the template first: `manifest.mjs upsert-batch --describe-command +"gs-admin --json cn chain --id {id}"` over a fresh list capture of that domain. + +Steps (from the consumer workspace, plugin loaded from the working tree; token pre-flight per +setup Phase 1 first): +1. `node .gs-superadmin/plugin/scripts/describe-batch.mjs --manifest /_manifest.json --domain --out-dir / --limit 3 --upgrade` + — with NO `--command`: the recorded template must clear the gate on its own merits. +2. Read the summary: `commandSource: "recorded"`, `documented ≥ 1`, no `aborted`; one chain's + doc under `//` with `doc_path` recorded on its entry (local read). +3. The other sanctioned route, one chain through the capture helper: + `node .gs-superadmin/plugin/scripts/capture.mjs --out .gs-superadmin/tmp/chain-probe.json -- gs-admin --json cn chain --id ` + — exit 0 and a JSON file, with no `--normalize` and no bare redirect anywhere. + +Pass bar: both scripts admit the lane; no manual per-asset path, no redirect-then-normalize. +A gate refusal on either ("not a describe-shaped read" / "not a capture-shaped read") +REOPENS F-456. Record the documented count and the chain id shape on the bus. + +## F-458 / #13 — one batch run PAST the token half-life: summary + manifest (banked 2026-09-14, builder, Session B) + +Owed by: the same tester round (Session B-V). +Tenant: the sandbox. Reads only against the tenant; the writes are the local manifest and docs. +Rig: the binding deadline must be the TOKEN, not the harness — run the batch from a terminal +(not the tool shell's ~2-minute timeout) on a domain with more undocumented or +`--upgrade`-eligible assets than the token's usable life covers at the observed rate (setup +Phase 1's pre-flight formula; at ~3 s/asset a fresh token's usable life covers roughly 600 +describes, so `--limit 800` on the largest stub domain runs past it). + +Steps: +1. `gs-admin login` fresh; note `whoami`'s remaining seconds. +2. Run describe-batch on that domain with the oversized `--limit` and let it stop on its own. +3. Read the summary: `aborted.reason: "auth"`, `aborted.lastError` carrying the CLI's + re-login sentence, `failures: []`, `failed: 0`, `documented: N`; stderr showed + `ABORTED after entries (auth)`. +4. Read the manifest locally: every entry the run reached is `documented` or at its prior + status; the count of entries with `status === "failed"` whose `error` contains + `gs-admin login` is 0. +5. `gs-admin login`, re-invoke the same command: the run resumes; the entry that was in + flight documents normally. +6. (#13's live confirmation, optional, harmless) on a SMALL domain, run describe-batch with + `--command` naming a describe whose id space cannot match — e.g. `gs-admin --json re r + describe --id {id}` over the scorecard domain, `--limit 10`: the run must stop after + exactly 5 spawns with `aborted.reason: "consecutive-failures"`, `after: 5`, five entries + marked `failed`; then restore them (`manifest.mjs mark --status stale` on each key, or + re-run the domain with its recorded command). + +Pass bar: steps 3–5 as stated (step 6 as stated if run). Any entry marked `failed` whose +recorded error is the re-login sentence REOPENS F-458; a walk of the asset list past five +consecutive identical failures REOPENS #13. diff --git a/plugins/gs-superadmin/.claude-plugin/plugin.json b/plugins/gs-superadmin/.claude-plugin/plugin.json index 48cd155..f9c8731 100644 --- a/plugins/gs-superadmin/.claude-plugin/plugin.json +++ b/plugins/gs-superadmin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gs-superadmin", - "version": "0.38.0", + "version": "0.39.0", "description": "Persistent Gainsight Admin CLI workspace — bootstrap, index, and operate on a Gainsight tenant via gs-admin.", "author": { "name": "BradleyDB" diff --git a/plugins/gs-superadmin/CHANGELOG.md b/plugins/gs-superadmin/CHANGELOG.md index 4bb189d..6d66fb0 100644 --- a/plugins/gs-superadmin/CHANGELOG.md +++ b/plugins/gs-superadmin/CHANGELOG.md @@ -5,6 +5,33 @@ marketplace doesn't pin versions — users get main — so entries describe what user who updates, not internal refactors. Entries before 0.8.0 were reconstructed from git history when this file was introduced. +## 0.39.0 — 2026-09-14 + +The describe loop stops itself, and says why. What a user gets by updating: a dead token +or a wrong `describeCommand` no longer walks the whole asset list — `describe-batch.mjs` +aborts a domain after five consecutive failed marks (issue #13) and aborts on the first +auth failure with nothing marked (F-458), reporting either as one new summary field, +`aborted: { reason: "consecutive-failures" | "auth", after, lastError }` (absent on a run +that did not stop early; `budgetExhausted`, `moreRemaining` and `failures` are unchanged). +A run that ended because the token died therefore never records that death as an asset's +`failed` state: on one live run seventeen healthy assets carried it with the auth error as +their reason; the entry in flight now keeps its status and is simply re-offered after +`gs-admin login` (real describe failures earlier in the same run still stand). + +- The read-only gate in `describe-batch.mjs` and `capture.mjs` admits a per-item describe + on its catalog `actionKey` as well as on the path's trailing word (F-456): `cn chain` + (`describe-job-chain`) and `re r execution` (`describe-execution`) are now describable + and capturable through both scripts, so the connectors-chains lane needs no manual + fallback. A committed sweep over the shipped catalog names any future refusal at + adoption time. +- Every `capture.mjs --paginate` fence in the skills quotes its `--out` value: Windows + PowerShell consumed the unquoted `{page}` placeholder and the command failed (issue #10). + The helper's refusal for a missing placeholder now names that cause. +- Setup Phase 5: the stop rule names the within-run limit and both `aborted` reasons; the + batch-sizing guidance names the nearer of the shell timeout and the token's usable life + as the binding deadline, and says to size against the slow end of observed rates. The + manual per-asset path carries the same auth exception. + ## 0.38.0 — 2026-09-11 Setup Phase 4's index-or-exclude decision is bound to the overlap check's numbers. What a diff --git a/plugins/gs-superadmin/MAINTAINERS.md b/plugins/gs-superadmin/MAINTAINERS.md index 9e4b523..7245d44 100644 --- a/plugins/gs-superadmin/MAINTAINERS.md +++ b/plugins/gs-superadmin/MAINTAINERS.md @@ -31,8 +31,8 @@ modules they import, `doc-lib.mjs` and `journal-lib.mjs`): |--------|---------| | `scripts/manifest.mjs` | Deterministic `_manifest.json` operations (init / upsert-batch / mark / next / stub / crawl / report / remove / exclude / block) — atomic writes, schema-checked; the skills never hand-edit manifest JSON. `upsert-batch` records each domain's id field, describe recipe, list command (`--list-command`, matched by the candidate gate), and modified-date field (`--date-field` / `--no-date-field`) in `domains_indexed` and reuses the recordings on re-runs, guarded against contradiction (`--allow-rekey` / `--allow-redate` override deliberate scheme changes); its summary warns on probable under-pagination (incoming list smaller than the domain's non-failed inventory) and on mass-stale flips — it never removes entries itself; `--partial` declares a deliberate-subset registration (gap-fill flows, F-313), which skips the under-pagination warning and leaves the domain's coverage stamp untouched. `exclude` / `block` persist the candidate gate's per-command decisions ("looked and said no" vs "could not look" — F-108/F-218), keyed by canonical catalog path; an exclusion records its evidence (F-449): exactly one of `--check ` (the candidate's own `check --out` file, whose `command` must match) or `--no-check ""`, with `--covered-by ` as the coverage claim the check's numbers must support (and must be made when they do), and `--recheck-after` on both record kinds | | `scripts/domain-candidates.mjs` | The setup Phase 4 candidate gate (F-108), read-only: `diff` derives every list-shaped tenant-wide command from the catalog and maps each to indexed / excluded / blocked / undecided against the manifest's recordings (`--require-decided` exits 1 while any candidate is undecided or a domain lacks a `listCommand` recording); `check` answers the GLOBAL "are these rows already indexed anywhere" overlap question over a captured list payload (fails loudly on unresolvable id fields, partial extractions, and — without `--allow-empty` — payloads with no items array), and with `--command "" --out ` writes its JSON as the evidence file `manifest.mjs exclude --check` reads (F-449); the diff's `excluded` rows carry `kind` (coverage / judgment / no-check / legacy), the evidence, and `recheckDue` | -| `scripts/describe-batch.mjs` | Sanctioned describe→doc→mark loop for one domain: selects the batch via `manifest.mjs next`, runs the describes sequentially with `{id}`/`{name}` substituted as literal argv (no shell — pipe-bearing names are safe; resolves the CLI's JS entry, so no Windows `.cmd` traps), writes one structured doc per asset, and marks each as it lands, emitting a stderr progress line every few describes plus a `domainProgress` summary field (documented/total; on `--upgrade` runs `{ full, metadata, failed, total }`, since stubs already count as documented). `--command` defaults to the describe recipe the domain's index recorded (`manifest.mjs upsert-batch --describe-command`); an explicit `--command` wins. For the domain recorded from `jo email templates` it auto-selects its template doc-mode (`--doc-mode template`) and for the one recorded from `jo programs list` its program doc-mode (`--doc-mode program`) — the recording decides, the naming rule's `journey-email-templates` / `journey` only as the no-recording fallback (F-429; the lane table is doc-lib's `RECORDED_LANES`) — writing compact docs instead of raw-JSON docs — rendering shared with `template-doc.mjs`/`program-doc.mjs` via `scripts/doc-lib.mjs`. Records a content fingerprint of each documented payload (volatile modified/updated fields dropped), and `--if-changed` uses it as a re-document gate: an unchanged payload skips the doc write and is just re-marked documented (`skippedUnchanged` in the summary), so a platform event that bumps modified dates en masse doesn't rewrite every doc. Enforces read-only fail-closed against the catalog — mutating or unknown commands are refused, recorded or passed. For `data-designer` it auto-selects its designer doc-mode (`--doc-mode designer`, GP-B5 W9): three describe levels per template — the template, one `--task-id` drilldown per task, one `--field` detail per show-field label (labels derived from the drilldown's tables by doc-lib's `designerTaskFieldLabels`, aggregation suffix stripped per its enumerated vocabulary; both flag spellings verified against the catalog before any spawn) — composed into ONE doc by doc-lib's `renderDesignerDoc` (the template payload with the drilldowns under `_kb`, T-3 v5). Cost is bounded per invocation by `--spawn-budget` (default 30 calls) and the doc is rewritten after every call, so a run cut off mid-template leaves an INCOMPLETE doc that the next invocation resumes from (`designerDocProgress`); the entry is marked documented only when every item is ok, failed (doc kept, doc_path recorded) when any item failed after all were attempted, and `--if-changed` skips only a COMPLETE composite of unchanged content | -| `scripts/capture.mjs` | The shipped capture helper (GP-B5 DS-17): runs one read-only `gs-admin` command (argv array, no shell — the CLI's JS entry resolved the same way as `describe-batch.mjs`) and BYTE-copies its stdout to `--out` as UTF-8 **without a BOM** via temp+rename (no decode — the child's bytes are preserved exactly) — the redirect-encoding rule the capturing skills used to restate as prose, carried by construction; `--normalize ` re-encodes an existing capture in place, tolerant of what PS 5.1 redirects write (UTF-16LE/BE with BOM, BOM'd UTF-8) and REFUSING, file untouched, anything whose decode would be lossy (BOM-less non-UTF-8 console-codepage captures, lone-surrogate UTF-16). Spawns the CLI itself, so the mutation guard never sees the embedded command: it gates fail-closed through doc-lib's shared `assertPlainGsAdminCommand` + `assertReadOnlyCommand` (non-gs-admin / shell-operator / unknown / catalog-mutating / ask-override / read-shape / write-endpoint all refused) with a capture-shaped policy built on the shared `READ_VERB_EXACT`: list-shaped by verb, actionKey, or summary (both of `domain-candidates.mjs`'s prongs), describe-shaped, the known per-item reads, and `dm deps check`. A failed child propagates its exit code and writes nothing — an earlier capture at `--out` is left intact and named as earlier. `--wait` (GP-B5 DS-26) bounded-polls the async `dm deps check` scan: re-run every `--wait-interval` s (default 15, floor 1 — each attempt is a real tenant request) up to `--wait-timeout` s (default 120, honored in full), writing only a payload whose `data.progressStatus.overallStatus` is COMPLETED — the envelope requirement is exactly the readers' rule, differential-locked against `parseLiveDepsAreas` in the suite; on timeout NOTHING is written and the non-zero exit names the elapsed time and last status ("not ready after N s", never a confident zero). `--paginate` (GP-B5 DS-30) owns the list-sweep pagination doctrine: `--page-flag ` (the command's paging flag, appended per round — never guessed; `none` = one reconciled fetch), a `{page}` placeholder in `--out` (one file per page), `--max-pages` safety stop; each page's envelope is scanned with doc-lib's `scanListEnvelope` (er-count's no-descend traversal + parse-don't-validate totals over the measured envelope variance) and the summary is the honesty report — pages fetched vs parsed, rows counted, every total with its path, and a verdict, with only `reconciled`/`unverified` exiting 0 (`mismatch`/`suspect`/`total-conflict`/`failed-page`/`safety-stop` exit non-zero; an unparseable page is a failed sweep page kept as evidence, never 0 entries). `build/check-doc-drift.mjs` check 14 enforces that skill captures route through this helper | +| `scripts/describe-batch.mjs` | Sanctioned describe→doc→mark loop for one domain: selects the batch via `manifest.mjs next`, runs the describes sequentially with `{id}`/`{name}` substituted as literal argv (no shell — pipe-bearing names are safe; resolves the CLI's JS entry, so no Windows `.cmd` traps), writes one structured doc per asset, and marks each as it lands, emitting a stderr progress line every few describes plus a `domainProgress` summary field (documented/total; on `--upgrade` runs `{ full, metadata, failed, total }`, since stubs already count as documented). `--command` defaults to the describe recipe the domain's index recorded (`manifest.mjs upsert-batch --describe-command`); an explicit `--command` wins. For the domain recorded from `jo email templates` it auto-selects its template doc-mode (`--doc-mode template`) and for the one recorded from `jo programs list` its program doc-mode (`--doc-mode program`) — the recording decides, the naming rule's `journey-email-templates` / `journey` only as the no-recording fallback (F-429; the lane table is doc-lib's `RECORDED_LANES`) — writing compact docs instead of raw-JSON docs — rendering shared with `template-doc.mjs`/`program-doc.mjs` via `scripts/doc-lib.mjs`. Records a content fingerprint of each documented payload (volatile modified/updated fields dropped), and `--if-changed` uses it as a re-document gate: an unchanged payload skips the doc write and is just re-marked documented (`skippedUnchanged` in the summary), so a platform event that bumps modified dates en masse doesn't rewrite every doc. Enforces read-only fail-closed against the catalog — mutating or unknown commands are refused, recorded or passed. For `data-designer` it auto-selects its designer doc-mode (`--doc-mode designer`, GP-B5 W9): three describe levels per template — the template, one `--task-id` drilldown per task, one `--field` detail per show-field label (labels derived from the drilldown's tables by doc-lib's `designerTaskFieldLabels`, aggregation suffix stripped per its enumerated vocabulary; both flag spellings verified against the catalog before any spawn) — composed into ONE doc by doc-lib's `renderDesignerDoc` (the template payload with the drilldowns under `_kb`, T-3 v5). Cost is bounded per invocation by `--spawn-budget` (default 30 calls) and the doc is rewritten after every call, so a run cut off mid-template leaves an INCOMPLETE doc that the next invocation resumes from (`designerDocProgress`); the entry is marked documented only when every item is ok, failed (doc kept, doc_path recorded) when any item failed after all were attempted, and `--if-changed` skips only a COMPLETE composite of unchanged content. Within-run abort (issue #13, F-458): five consecutive `failed` marks stop the domain with the marks standing, and a FAILED spawn carrying the CLI's re-login sentence (doc-lib `isAuthDeath`, version-stamped) stops it on the first sighting with nothing marked (a designer entry keeps its composite with the item pending); either is reported as one ADDITIVE summary field `aborted: { reason: "consecutive-failures" | "auth", after, lastError }` — absent on a run that did not stop early; `budgetExhausted`, `moreRemaining` and `failures` keep their shapes (the setup skill branches on them: contract) | +| `scripts/capture.mjs` | The shipped capture helper (GP-B5 DS-17): runs one read-only `gs-admin` command (argv array, no shell — the CLI's JS entry resolved the same way as `describe-batch.mjs`) and BYTE-copies its stdout to `--out` as UTF-8 **without a BOM** via temp+rename (no decode — the child's bytes are preserved exactly) — the redirect-encoding rule the capturing skills used to restate as prose, carried by construction; `--normalize ` re-encodes an existing capture in place, tolerant of what PS 5.1 redirects write (UTF-16LE/BE with BOM, BOM'd UTF-8) and REFUSING, file untouched, anything whose decode would be lossy (BOM-less non-UTF-8 console-codepage captures, lone-surrogate UTF-16). Spawns the CLI itself, so the mutation guard never sees the embedded command: it gates fail-closed through doc-lib's shared `assertPlainGsAdminCommand` + `assertReadOnlyCommand` (non-gs-admin / shell-operator / unknown / catalog-mutating / ask-override / read-shape / write-endpoint all refused) with a capture-shaped policy built on doc-lib's shared `isDescribeRead` (describe-shaped by catalog `actionKey` or by the path's trailing word, plus the `READ_VERB_EXACT` per-item reads — F-456) and list shapes by verb, actionKey, or summary (both of `domain-candidates.mjs`'s prongs), and `dm deps check`. A failed child propagates its exit code and writes nothing — an earlier capture at `--out` is left intact and named as earlier. `--wait` (GP-B5 DS-26) bounded-polls the async `dm deps check` scan: re-run every `--wait-interval` s (default 15, floor 1 — each attempt is a real tenant request) up to `--wait-timeout` s (default 120, honored in full), writing only a payload whose `data.progressStatus.overallStatus` is COMPLETED — the envelope requirement is exactly the readers' rule, differential-locked against `parseLiveDepsAreas` in the suite; on timeout NOTHING is written and the non-zero exit names the elapsed time and last status ("not ready after N s", never a confident zero). `--paginate` (GP-B5 DS-30) owns the list-sweep pagination doctrine: `--page-flag ` (the command's paging flag, appended per round — never guessed; `none` = one reconciled fetch), a `{page}` placeholder in `--out` (one file per page), `--max-pages` safety stop; each page's envelope is scanned with doc-lib's `scanListEnvelope` (er-count's no-descend traversal + parse-don't-validate totals over the measured envelope variance) and the summary is the honesty report — pages fetched vs parsed, rows counted, every total with its path, and a verdict, with only `reconciled`/`unverified` exiting 0 (`mismatch`/`suspect`/`total-conflict`/`failed-page`/`safety-stop` exit non-zero; an unparseable page is a failed sweep page kept as evidence, never 0 entries). `build/check-doc-drift.mjs` check 14 enforces that skill captures route through this helper | | `scripts/relationships-build.mjs` | Sanctioned Phase 6 map generator: derives `relationships/field-to-rule.md`, `field-to-scorecard.md`, `process-maps.md`, and `program-to-template.md` (program → email template via GSID co-occurrence; there is deliberately no journey→rule map — rules never feed program participants) from the KB's full-describe docs (`_flatMappings` semantics and their verification basis documented in the script header) — coverage headers per file, unknown actionTypes reported rather than dropped, dangling measure references flagged | | `scripts/template-doc.mjs` | Converts captured `jo email template --id` describe payloads into compact KB docs (metadata + plain-text body; the ~50 KB HTML rendering is dropped) without passing payloads through model context — the standalone path for one-off payloads (e.g. UI-export id recovery); bulk runs use `describe-batch.mjs`'s template doc-mode, which imports the same renderer from `scripts/doc-lib.mjs` | | `scripts/program-doc.mjs` | Converts captured `jo p describe --id` payloads (~287 KB/program, mostly flow-canvas geometry) into compact KB docs — the semantic flow skeleton (node types/names, branch conditions, participant source with PowerList config verbatim, template references, timers) with geometry dropped via a conservative 1.0.4-scoped drop-list; the standalone path mirroring `template-doc.mjs`; bulk runs use `describe-batch.mjs`'s program doc-mode (same renderer from `scripts/doc-lib.mjs`) | diff --git a/plugins/gs-superadmin/scripts/capture.mjs b/plugins/gs-superadmin/scripts/capture.mjs index dc87966..85ed479 100644 --- a/plugins/gs-superadmin/scripts/capture.mjs +++ b/plugins/gs-superadmin/scripts/capture.mjs @@ -140,7 +140,7 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { writeFileAtomicSync, makeCliHelpers, findWorkspaceCatalog, makeCommandResolver, - assertReadOnlyCommand, assertPlainGsAdminCommand, resolveCliArgv, READ_VERB_EXACT, + assertReadOnlyCommand, assertPlainGsAdminCommand, resolveCliArgv, isDescribeRead, sleepMs, decideEntryArray, entryDecisionReason, DOTTED_PATH_RE, } from "./doc-lib.mjs"; // The shared printable clamp (journal-lib exports it; the guard keeps its own @@ -339,7 +339,11 @@ if (paginate && pageFlagName) { `it each round; remove it from the command (a fixed page would re-fetch the same page forever)` ); if (!outPath.includes("{page}")) - fail("--paginate with a page flag needs a literal {page} placeholder in --out (one file per page)"); + fail( + "--paginate with a page flag needs a literal {page} placeholder in --out (one file per page) — " + + "if you wrote one, the shell consumed it: quote the --out value (Windows PowerShell eats an unquoted {page}; " + + "single quotes are right in PowerShell and bash) (issue #10)" + ); } // The requested page size, read from the command's own tokens (the caller // already spells `--limit 200` there; a second knob would drift): used only @@ -371,16 +375,20 @@ const { cmd: matched, rest } = makeCommandResolver(catalog).resolveTokens(args); // actionKey is `dependency-config`) — plus the resolved-path verb form; // actionKey/summary are absent on hand-trimmed workspace catalogs, so // the verb clause stands alone there; -// - describe-shaped actions and the shared per-item/scheduling read set -// (READ_VERB_EXACT — one copy, in doc-lib, with the adoption-time -// re-audit stamp); +// - describe-shaped actions and the shared per-item/scheduling read set — +// doc-lib's isDescribeRead, the ONE describe-shape predicate (F-456: a +// describe-shaped actionKey admits on its own merits, so `cn chain` +// (describe-job-chain) is capturable here exactly as describe-batch +// describes it; the trailing word admits on the describe shape or +// READ_VERB_EXACT, one copy in doc-lib with the adoption-time re-audit +// stamp); // - `check` — the async `dm deps check` dependency scan (actionKey // object-dependencies), the deps-report/email-report live capture. // Everything else is refused; the shared gate's mutating/override/endpoint // checks backstop this list in both directions. -const CAPTURE_VERB_EXACT = new Set([...READ_VERB_EXACT, "check"]); const isCaptureRead = (verb, cmd) => - (typeof verb === "string" && (/^(describe|list)(-|$)/.test(verb) || CAPTURE_VERB_EXACT.has(verb))) || + isDescribeRead(verb, cmd) || + (typeof verb === "string" && (/^list(-|$)/.test(verb) || verb === "check")) || (typeof cmd?.actionKey === "string" && /^list(-|$)/.test(cmd.actionKey)) || (typeof cmd?.summary === "string" && /^List\b/.test(cmd.summary)); assertReadOnlyCommand({ diff --git a/plugins/gs-superadmin/scripts/describe-batch.mjs b/plugins/gs-superadmin/scripts/describe-batch.mjs index a46b270..5bdbe92 100644 --- a/plugins/gs-superadmin/scripts/describe-batch.mjs +++ b/plugins/gs-superadmin/scripts/describe-batch.mjs @@ -120,7 +120,45 @@ // // Output: one JSON summary on stdout (including `domainProgress`, the domain's // documented/total counts after the batch). Non-zero exit + stderr message on -// error. While the batch runs, a stderr progress line is emitted every few +// error. +// +// Within-run abort (issue #13, F-458): two ways a run stops before its batch +// is exhausted, both reported as ONE additive summary field — +// `aborted: { reason: "consecutive-failures" | "auth", after: , lastError }` +// — ABSENT on a run that did not abort (the setup skill branches on the one +// field; `budgetExhausted`, `moreRemaining` and `failures` keep their shapes +// and meanings, and this is a contract: never remove or re-type them). +// `after` is the number of entries this run ATTEMPTED, the aborting one +// included — per top-level entry, never per drilldown spawn. Exit stays 0: +// the run completed and its summary is honest; `moreRemaining` reads true. +// "consecutive-failures" — CONSECUTIVE_FAILURE_LIMIT entries in a row ended +// in a `failed` mark (every markFailed class: a failed or timed-out +// spawn, unexpected output, a renderer refusal, a missing {name}; a +// designer entry counts ONCE whatever its drilldown count). A documented +// or skipped-unchanged entry resets the count; a PERMANENT designer field +// gap never marks failed and so never counts; budget exhaustion is not a +// failure. The marks already written stand exactly as written (they were +// real describe failures) — the loop stops so a dead describeCommand or a +// wrong id field costs five spawns, not the whole asset list. The limit +// is a constant, not a flag (ruled 2026-09-14: the issue's number, high +// enough that a handful of broken assets in a healthy domain never trips +// it). The skill's BETWEEN-invocation stop rule is unchanged. +// "auth" — the CLI could not obtain a bearer token. Every describe after +// that fails identically until the user logs in, so the FIRST sighting +// aborts and NOTHING is marked: the in-flight entry keeps its status, +// like the budget-exhaustion path. `failed` means "the CLI could not +// describe this asset", never "the run ended while this asset was in +// flight" (F-458: 17 healthy assets carried that wrong durable state). +// Classified only inside the failure branch — non-zero exit or spawn +// error; the CLI's shared handler (dist/commands/base.js, +// BaseCommand.catch) writes `Error: ` to stderr and exits 1 for +// every thrown error, so a successful describe is never reclassified by +// its stderr (ruled 2026-09-14) — by the CLI's own re-login instruction +// on stderr or stdout: doc-lib's isAuthDeath (shared with capture.mjs, +// the other script that spawns this CLI). `domainProgress` is re-read +// at an abort so the summary's counts include the aborting entry's mark. +// +// While the batch runs, a stderr progress line is emitted every few // describes (`[describe-batch] 45/120 in batch — domain 380/473 documented`) // so long chunks are never silent. On --upgrade runs the depth is the metric, // not the status — metadata stubs already count as documented, so @@ -140,7 +178,7 @@ import { renderDesignerDoc, designerDocProgress, designerDrilldownStats, designerTaskFieldLabels, splitTrailingGroup, parseDocJson, normalizeText, writeFileAtomicSync, readJsonFile, makeCliHelpers, findWorkspaceCatalog, makeCommandResolver, - assertReadOnlyCommand, assertPlainGsAdminCommand, resolveCliArgv, READ_VERB_EXACT, DESCRIBE_NONE, RECORDED_LANES } from "./doc-lib.mjs"; + assertReadOnlyCommand, assertPlainGsAdminCommand, resolveCliArgv, isDescribeRead, isAuthDeath, DESCRIBE_NONE, RECORDED_LANES } from "./doc-lib.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const MANIFEST_SCRIPT = join(here, "manifest.mjs"); @@ -286,16 +324,19 @@ const { cmd: matched, rest } = makeCommandResolver(catalog).resolveTokens(tokens // still pin them through it (A-6). What stays HERE is this script's POLICY — // the describe-shaped read predicate: // -// The exact-name allowlist (READ_VERB_EXACT) is shared from doc-lib — its -// rationale, the audited catalog-version stamp, and the adoption-time -// re-audit tripwire live there (review round, B5 W4: two copies meant the -// tripwire pinned only one). This script's policy composes the describe -// shape onto it and refuses everything else; the shared gate's endpoint -// check still backstops any future write that happens to be named `get`. -const isReadVerb = (n) => typeof n === "string" && (/^describe(-|$)/.test(n) || READ_VERB_EXACT.has(n)); +// The predicate is doc-lib's isDescribeRead (F-456): a describe-shaped +// actionKey admits on its own merits (`cn chain` is describe-job-chain — its +// path ends in a noun and the old trailing-word test refused it, and capture +// with it), the trailing word admits on the describe shape or the shared +// exact-name allowlist (READ_VERB_EXACT — its rationale, the audited +// catalog-version stamp, and the adoption-time re-audit tripwire live in +// doc-lib; review round, B5 W4: two copies meant the tripwire pinned only +// one), and a hand-trimmed catalog with no actionKey decides on the word +// alone. Everything else is refused; the shared gate's endpoint check still +// backstops any future write that happens to be named `get`. assertReadOnlyCommand({ matched, rest, hooksDir: join(here, "..", "hooks"), fail, printable, - isRead: isReadVerb, + isRead: isDescribeRead, shapeNoun: "a describe-shaped read", runsNoun: "read-only describes", }); @@ -496,6 +537,25 @@ const spawnCli = (args) => { // else — timeout, transport, exit without that sentence — is retryable. const FIELD_NOT_FOUND = /No field found on task/; +// ── Within-run abort (issue #13, F-458 — the header's contract) ───────────── +// The auth classifier (the CLI's re-login sentence, version-stamped) is +// doc-lib's isAuthDeath — one home for both spawn-capable scripts. +const CONSECUTIVE_FAILURE_LIMIT = 5; +let attempted = 0; // top-level entries this run attempted, the aborting one included +let consecutiveFailures = 0; +/** @type {{reason: "consecutive-failures" | "auth", after: number, lastError: string} | null} */ +let aborted = null; +/** @param {"consecutive-failures" | "auth"} reason @param {string} lastError */ +const abort = (reason, lastError) => { + aborted = { reason, after: attempted, lastError: printable(lastError, 200) }; + // Never silent: the summary carries the field, stderr says it as it happens. + console.error(`[describe-batch] ABORTED after ${attempted} ${attempted === 1 ? "entry" : "entries"} (${reason}): ${aborted.lastError}`); + // The loop stops between progress ticks, so the last snapshot may predate + // the aborting entry's own mark (review round): re-read once, best-effort. + const dp = domainProgress(); + if (dp) lastProgress = dp; +}; + // ── Designer mode: the per-entry three-level drilldown with resume ────────── // Returns "documented" | "failed" | "budget". Writes the composite doc after // EVERY spawn (doc-lib renderDesignerDoc; atomic temp+rename) so the doc on @@ -534,12 +594,19 @@ function runDesignerEntry({ entry, payload, core, fp, base, args, prior }) { renderDesignerDoc({ name: entry.name ?? null, key: entry.key, id: String(entry.id), payload, envelope: core !== payload, kb, capturedAt: new Date().toISOString() }) ); write(); // the template level is captured even if the budget ends here + // The token died mid-entry (F-458): set by drillSpawn, read by the loops + // below, which then stop like the budget and record NO item outcome. + /** @type {string | null} */ + let authDead = null; // One spawn of either lower level: budget-charged, parsed, unwrapped, and // validated by the caller's `pick`. Returns { value } | { why, notFound }. const drillSpawn = (level, extra, pick) => { budgetLeft--; drill.spawns[level]++; const r = spawnCli([...args, ...extra]); - if (!r.ok) return { value: null, why: printable(r.why, 200), notFound: FIELD_NOT_FOUND.test(r.stderr) || FIELD_NOT_FOUND.test(r.stdout) }; + if (!r.ok) { + if (isAuthDeath(r)) authDead = printable(r.why, 200); + return { value: null, why: printable(r.why, 200), notFound: FIELD_NOT_FOUND.test(r.stderr) || FIELD_NOT_FOUND.test(r.stdout) }; + } let value = null; try { value = pick(unwrapOnce(JSON.parse(r.stdout))); } catch { /* non-JSON: reported below */ } return value === null ? { value: null, why: `${level} output is not the expected JSON shape`, notFound: false } : { value, why: null, notFound: false }; @@ -549,6 +616,7 @@ function runDesignerEntry({ entry, payload, core, fp, base, args, prior }) { if (kb.tasks[tid].status !== "ok") { if (budgetLeft <= 0) { stopped = true; break; } const r = drillSpawn("task", [DESIGNER_FLAGS.task, tid], (d) => (isRecord(d) ? d : null)); + if (authDead !== null) { stopped = true; break; } if (!r.value) { kb.tasks[tid] = { status: "failed", error: r.why }; drill.thisRun.tasksFailed++; @@ -595,6 +663,7 @@ function runDesignerEntry({ entry, payload, core, fp, base, args, prior }) { for (const spelling of spellings) { if (budgetLeft <= 0) { stopped = true; break; } const r = drillSpawn("field", [DESIGNER_FLAGS.task, tid, DESIGNER_FLAGS.field, spelling], (d) => (isRecord(d) && Array.isArray(d._taskFieldDetail) ? d._taskFieldDetail : null)); + if (authDead !== null) { stopped = true; break; } if (r.value) { outcome = { rows: r.value, spelling }; break; } outcome = { why: r.why, notFound: r.notFound }; if (!r.notFound) break; // retryable (timeout/transport): do not spend the alternate spelling @@ -614,6 +683,7 @@ function runDesignerEntry({ entry, payload, core, fp, base, args, prior }) { if (stopped) break; } const s = designerDrilldownStats(kb); + if (authDead !== null) return { outcome: "auth", error: authDead, stats: s }; if (stopped) return { outcome: "budget", stats: s }; if (s.complete) return { outcome: "documented", stats: s }; const n = s.tasksFailed + s.fieldsFailed; @@ -622,13 +692,21 @@ function runDesignerEntry({ entry, payload, core, fp, base, args, prior }) { } for (const entry of batch.entries) { + // The previous entry's failed mark may have tripped the limit (markFailed + // below records the abort after writing that mark); stop before spawning. + if (aborted) break; // Designer mode: a fresh entry needs its template describe AND at least // one drilldown to make progress (the floor on --spawn-budget says why). if (docMode === "designer" && budgetLeft <= 0) { budgetExhausted = true; break; } + attempted++; const markFailed = (msg, extra = []) => { runManifest(["mark", "--key", entry.key, "--status", "failed", "--error", printable(msg, 200), ...extra]); failures.push({ key: entry.key, error: printable(msg, 200) }); progress(); + // Issue #13: every failed mark counts toward the within-run limit; the + // documented and skipped-unchanged marks reset it. Checked HERE so the + // aborting entry's own mark is already written when the loop stops. + if (++consecutiveFailures >= CONSECUTIVE_FAILURE_LIMIT) abort("consecutive-failures", msg); }; if (tokens.includes("{name}") && (entry.name == null || entry.name === "")) { markFailed("no name recorded in manifest — cannot substitute {name}"); @@ -638,6 +716,8 @@ for (const entry of batch.entries) { if (docMode === "designer") { budgetLeft--; drill.spawns.template++; } const res = spawnCli(args); if (!res.ok) { + // F-458: the token died, not the asset — nothing marked, the run stops. + if (isAuthDeath(res)) { abort("auth", res.why); break; } markFailed(res.why); continue; } @@ -660,6 +740,17 @@ for (const entry of batch.entries) { // run finds it. const base = claimBaseName(entry.id); const relPath = `${outDir.replace(/\\/g, "/").replace(/\/$/, "")}/${base}.md`; + // The full-depth documented mark, one spelling for the raw/template/program + // path and the designer composite (A-1); a success resets the within-run + // failure count (issue #13). + const markDocumented = () => { + const markArgs = ["mark", "--key", entry.key, "--status", "documented", "--depth", "full", "--doc-path", relPath]; + if (fp) markArgs.push("--fingerprint", fp); + runManifest(markArgs); + consecutiveFailures = 0; + docs.push(relPath); + progress(); + }; // Designer resume state: the composite already on disk for this entry, if // any — under the recorded doc_path, else under the claimed name (an // unmarked budget-cut doc; the two usually coincide, so the list is @@ -695,6 +786,7 @@ for (const entry of batch.entries) { // same template content — a summary-only doc from an earlier plugin // version, or a budget-cut partial, documents normally instead. runManifest(["mark", "--key", entry.key, "--status", "documented"]); + consecutiveFailures = 0; skippedUnchanged++; unchangedKeys.push(entry.key); progress(); @@ -750,17 +842,23 @@ for (const entry of batch.entries) { progress(); break; } + if (result.outcome === "auth") { + // The token died inside a drilldown (F-458, one level down): the + // composite on disk records no outcome for it — the item stays + // pending — nothing is marked, and the run stops; the next invocation + // resumes from the doc exactly as after a budget cut. The top-level + // `aborted` is the one record of it (no per-entry counter: a template- + // spawn death takes the same route one level up). + abort("auth", result.error); + break; + } if (result.outcome === "failed") { drill.entries.failed++; markFailed(result.error, existsSync(resolve(outDir, `${base}.md`)) ? ["--doc-path", relPath] : []); continue; } drill.entries.documented++; - const markArgs = ["mark", "--key", entry.key, "--status", "documented", "--depth", "full", "--doc-path", relPath]; - if (fp) markArgs.push("--fingerprint", fp); - runManifest(markArgs); - docs.push(relPath); - progress(); + markDocumented(); continue; } else { // Describe payloads commonly wrap the asset in a `data` envelope. @@ -774,14 +872,12 @@ for (const entry of batch.entries) { }); } writeFileSync(resolve(outDir, `${base}.md`), doc, "utf8"); - const markArgs = ["mark", "--key", entry.key, "--status", "documented", "--depth", "full", "--doc-path", relPath]; - if (fp) markArgs.push("--fingerprint", fp); - runManifest(markArgs); - docs.push(relPath); - progress(); + markDocumented(); } -const more = runManifest(selectionArgs(1)); +// The moreRemaining probe is one more manifest.mjs spawn; a run that stopped +// early already knows the answer, so it is not spawned (review round). +const more = budgetExhausted || aborted !== null ? { count: 1 } : runManifest(selectionArgs(1)); console.log( JSON.stringify( { @@ -789,6 +885,9 @@ console.log( domain, docMode, commandSource, + // ADDITIVE (issue #13, F-458): present only on a run that stopped early + // — { reason, after, lastError }; see the header's contract. + ...(aborted ? { aborted } : {}), selected: batch.count, documented: docs.length, skippedUnchanged, @@ -798,7 +897,7 @@ console.log( docs, ...(docMode === "designer" ? { drilldowns: { budget: spawnBudget, spawnsUsed: spawnBudget - budgetLeft, budgetExhausted, ...drill } } : {}), domainProgress: lastProgress ?? domainProgress(), - moreRemaining: more.count > 0 || budgetExhausted, + moreRemaining: more.count > 0 || budgetExhausted || aborted !== null, }, null, 2 diff --git a/plugins/gs-superadmin/scripts/doc-lib.mjs b/plugins/gs-superadmin/scripts/doc-lib.mjs index a97148c..cfd8470 100644 --- a/plugins/gs-superadmin/scripts/doc-lib.mjs +++ b/plugins/gs-superadmin/scripts/doc-lib.mjs @@ -1705,12 +1705,69 @@ export function indexedElsewhere(inventory, ids, excludeDomain = null) { // `jo dd get`, `jo s get`, the two `list-and-describe` combos, and the six // scheduling/Events-Framework reads added at 1.0.6 — and is re-audited by // hand at each CLI adoption (check-stale-facts pins the version stamp in -// this sentence). Callers COMPOSE on it: describe-batch adds the -// describe-shape regex; capture adds list shapes and `check`. +// this sentence). Callers COMPOSE on it through isDescribeRead below: +// describe-batch uses that predicate as its whole policy; capture adds list +// shapes and `check`. export const READ_VERB_EXACT = new Set([ "template", "measures", "get", "list-and-describe", "schedules", "topics", "events", "s3-tasks", "event-curl", ]); +// The describe-shaped read predicate BOTH spawn-capable scripts compose on +// (F-456). The gate hands it the resolved path's trailing word AND the +// matched catalog entry; until F-456 only the word was read, so a per-item +// describe whose path ends in a noun — `cn chain`, actionKey +// describe-job-chain — was refused by describe-batch and capture alike, +// leaving no sanctioned route to document the domain. The catalog's +// `actionKey` is the action's own name (generated from the manifests, +// semantically stable), so a describe-shaped key admits on its own merits. +// The trailing word stays a second admit, not a fallback only: the +// allowlisted reads carry non-describe keys (`jo e template` is +// get-email-template, `sc measures` get-scorecard-measures — measured at +// the pin, 2026-09-14), so "key instead of word" would refuse them. A +// hand-trimmed workspace catalog carries no actionKey, and there the word +// decides alone. `describe` is matched as a whole segment (`describe` or +// `describe-…`), never as a prefix of a longer word. Never keyed on +// `mutating` (F-456: two upstream mislabelled writers carried false); the +// shared gate's endpoint check backstops every admit either way. +export const DESCRIBE_SHAPE_RE = /^describe(-|$)/; +/** + * @param {string | undefined} verb the resolved catalog path's trailing word + * @param {{actionKey?: unknown} | null | undefined} cmd the matched catalog entry (fields absent on trimmed catalogs) + * @returns {boolean} + */ +export function isDescribeRead(verb, cmd) { + const key = cmd && typeof cmd === "object" && typeof cmd.actionKey === "string" ? cmd.actionKey : null; + if (key !== null && DESCRIBE_SHAPE_RE.test(key)) return true; + return typeof verb === "string" && (DESCRIBE_SHAPE_RE.test(verb) || READ_VERB_EXACT.has(verb)); +} + +// The CLI's re-login instruction — the sentence every auth-path throw in the +// v1.0.9 package's dist/core/auth/index.js ends with (F-458). Four literals, +// all session-wide (the CLI cannot obtain a bearer token; no command after +// them succeeds until `gs-admin login`): "No stored token found. Run +// `gs-admin login` to authenticate."; "Access token has expired and no +// refresh token is available. Run `gs-admin login` to re-authenticate."; +// "Token expired and silent refresh failed (). Run `gs-admin login` +// to re-authenticate." — the half-life case setup Phase 1's pre-flight +// names; "Token refresh failed (). Run `gs-admin login` to +// re-authenticate.". Pinned on the shared sentence, not on a cause, so all +// four classify the same way (ruled 2026-09-14). Other CLI mentions of login +// ("Run: gs-admin login", "run 'gs-admin login'") are worded differently and +// do not match. Classified only on a FAILED spawn — the CLI's shared handler +// (dist/commands/base.js BaseCommand.catch) writes `Error: ` to +// stderr and exits 1 for every thrown error, so a successful command is +// never reclassified by its stderr. One home for both spawn-capable scripts +// (describe-batch stops its loop on it; capture may classify the same death +// later): the version citation is the adoption-time tripwire +// (check-stale-facts, FACT_CARRIERS) — re-read that file at the next pin. +export const AUTH_DEATH = /Run `gs-admin login` to (re-)?authenticate/; +/** + * @param {{ok: boolean, stdout: string, stderr: string}} r a spawn result + * @returns {boolean} + */ +export function isAuthDeath(r) { + return !r.ok && (AUTH_DEATH.test(r.stderr) || AUTH_DEATH.test(r.stdout)); +} // Argv hygiene for a command a spawn-capable script is about to run — shared // for the same F-284 reason as the gate below (review round: the first-token diff --git a/plugins/gs-superadmin/skills/audit/SKILL.md b/plugins/gs-superadmin/skills/audit/SKILL.md index 3e0755a..3a3da90 100644 --- a/plugins/gs-superadmin/skills/audit/SKILL.md +++ b/plugins/gs-superadmin/skills/audit/SKILL.md @@ -80,9 +80,9 @@ with `--json`, captured **to a file** through the shipped capture helper (bulk p never enter context; the helper writes clean UTF-8 — no BOM — on any shell; never a bare shell redirect. Rule canon: setup Phase 4): ``` -node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out .gs-superadmin/tmp/audit-rules-{page}.json -- gs-admin --json re r list --limit 200 -node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --items-path data.data --out .gs-superadmin/tmp/audit-reports-{page}.json -- gs-admin --json rp list --limit 200 -node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag none --out .gs-superadmin/tmp/audit-scorecards.json -- gs-admin --json sc list --limit 10000 +node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out '.gs-superadmin/tmp/audit-rules-{page}.json' -- gs-admin --json re r list --limit 200 +node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --items-path data.data --out '.gs-superadmin/tmp/audit-reports-{page}.json' -- gs-admin --json rp list --limit 200 +node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag none --out '.gs-superadmin/tmp/audit-scorecards.json' -- gs-admin --json sc list --limit 10000 ``` **Page each domain to exhaustion — a single default call is never the inventory.** The diff --git a/plugins/gs-superadmin/skills/deprecate/SKILL.md b/plugins/gs-superadmin/skills/deprecate/SKILL.md index b3af916..a609f0a 100644 --- a/plugins/gs-superadmin/skills/deprecate/SKILL.md +++ b/plugins/gs-superadmin/skills/deprecate/SKILL.md @@ -55,7 +55,7 @@ with the domain describe (rules: `re r describe --id `) — the manifest key spellings (`report-reports/`); the name grep finds the key whatever the prefix, and `report`'s `byDomain` lists the names in use (F-429). 2. **Live search (fallback if not in the KB — e.g. the asset postdates the last refresh).** - - Rules: `node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out .gs-superadmin/tmp/deprecate-list-{page}.json -- gs-admin --json re r list --search '' --limit 200` + - Rules: `node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out '.gs-superadmin/tmp/deprecate-list-{page}.json' -- gs-admin --json re r list --search '' --limit 200` (`--search` is a server-side partial name match; the paginate mode exhausts the pages, so a name with more matches than one page holds is never truncated — `{page}` is literal, the script substitutes it, one file per page; read every @@ -64,7 +64,7 @@ with the domain describe (rules: `re r describe --id `) — the manifest key - Reports: no name filter on `rp list` — sweep the whole list through the same paginate mode, stating the rows path (the envelope carries a root `alerts` block beside the rows; canon: setup Phase 4): - `node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --items-path data.data --out .gs-superadmin/tmp/deprecate-reports-{page}.json -- gs-admin --json rp list --limit 200` + `node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --items-path data.data --out '.gs-superadmin/tmp/deprecate-reports-{page}.json' -- gs-admin --json rp list --limit 200` then match the name against every page file locally. The sweep's verdict decides what the pages prove (canon: setup Phase 4): `reconciled` is the complete list; `unverified` (also exit 0 — no payload total) is the CLI-reachable set, and its @@ -163,7 +163,7 @@ file's prefix format.) Build step 3's plan table the same way. 1. If step 2's describe showed a non-null `nextScheduledRun` — or the rule appears in the SCHEDULE-filtered list, swept through the paginate mode (canon: setup Phase 4 — the mode exhausts the pages, so a full page is never where the evidence ends): - `node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out .gs-superadmin/tmp/deprecate-schedule-{page}.json -- gs-admin --json re r list --search '' --filter-execution-type SCHEDULE --limit 200` + `node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out '.gs-superadmin/tmp/deprecate-schedule-{page}.json' -- gs-admin --json re r list --search '' --filter-execution-type SCHEDULE --limit 200` with a returned row (any page file) whose **`ruleId` equals ``** — run `gs-admin re r delete-schedule --id `. This search is the **primary** detection path, not an edge case: `nextScheduledRun` means "a future run is pending", not "a diff --git a/plugins/gs-superadmin/skills/email-report/SKILL.md b/plugins/gs-superadmin/skills/email-report/SKILL.md index daece55..9fe28cf 100644 --- a/plugins/gs-superadmin/skills/email-report/SKILL.md +++ b/plugins/gs-superadmin/skills/email-report/SKILL.md @@ -87,7 +87,7 @@ without a payload ever entering context (rule canon: setup Phase 4's "List exhaustively"; clean UTF-8, no BOM, any shell; never a bare shell redirect): ``` -node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out .gs-superadmin/tmp/er-plist-{page}.json -- gs-admin --json jo p list --limit 200 +node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out '.gs-superadmin/tmp/er-plist-{page}.json' -- gs-admin --json jo p list --limit 200 ``` `{page}` is literal — the script substitutes it, one file per page (`er-plist-1.json` diff --git a/plugins/gs-superadmin/skills/refresh/SKILL.md b/plugins/gs-superadmin/skills/refresh/SKILL.md index 83aa7a2..effe06e 100644 --- a/plugins/gs-superadmin/skills/refresh/SKILL.md +++ b/plugins/gs-superadmin/skills/refresh/SKILL.md @@ -91,7 +91,7 @@ the shipped capture helper (clean UTF-8, no BOM, any shell; never a bare shell redirect — rule canon: setup Phase 4; prefer a scratch `.mjs` file over a `node -e` one-liner when processing the files): ``` -node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out .gs-superadmin/tmp/--{page}.json -- gs-admin --json --limit 200 +node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out '.gs-superadmin/tmp/--{page}.json' -- gs-admin --json --limit 200 ``` Add `--items-path ` for a command whose envelope carries a second array beside the rows (`rp list`: `--items-path data.data` — its root `alerts` block fills on a diff --git a/plugins/gs-superadmin/skills/setup/SKILL.md b/plugins/gs-superadmin/skills/setup/SKILL.md index 03c06d8..aae65ba 100644 --- a/plugins/gs-superadmin/skills/setup/SKILL.md +++ b/plugins/gs-superadmin/skills/setup/SKILL.md @@ -176,10 +176,13 @@ reconciliation, and the suspect-round-count honesty (this and the next paragraph the canonical statements of the capture and pagination rules — the other capturing and sweeping skills carry a one-line paraphrase pointing here): ``` -node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out .gs-superadmin/tmp/--{page}.json -- gs-admin --json --limit 200 +node .gs-superadmin/plugin/scripts/capture.mjs --paginate --page-flag page --out '.gs-superadmin/tmp/--{page}.json' -- gs-admin --json --limit 200 ``` `{page}` is literal — the SCRIPT substitutes it (one file per page; keep every page -file). The helper spawns the CLI itself (argv, no shell) and writes clean UTF-8 with no +file). Keep the `--out` value quoted as the fence spells it: Windows PowerShell +consumes an unquoted `{page}` (the script then refuses the run — the placeholder is +gone — rather than writing a mis-named file), and single quotes are literal in both +shells the tools use, PowerShell and bash. The helper spawns the CLI itself (argv, no shell) and writes clean UTF-8 with no BOM on every shell — never capture with a bare shell redirect, whose encoding is the shell's choice (an ill-encoded file is one the manifest script's JSON parser rejects). If a payload was ever captured by hand with a redirect, re-encode it before parsing: @@ -540,7 +543,14 @@ safe unquoted (substitution rules and identifier cases: content fingerprint per documented entry, which `/gs-superadmin:refresh --document` passes `--if-changed` against so a later re-describe rewrites a doc only when the payload genuinely changed. Size `--limit` so one invocation -finishes inside the harness's ~2-minute shell timeout (**~10–15 describes** is +finishes inside the **nearer** of two deadlines — the harness's ~2-minute shell +timeout, and the token's usable life from the Phase 1 pre-flight (a token that +dies mid-batch ends the run, not the assets: the script reports it as +`aborted.reason: "auth"`, below, and marks nothing) — and size against the +**slow end** of the rates you have observed, because a small sample under-predicts +a large batch (measured on 1.0.9, one tenant: 2.36 s/asset over 25 assets, 2.77 +over 275, 3.09 over 311 — a batch sized from the 25-asset rate overran the token). +Within the shell timeout, **~10–15 describes** is the norm, journey programs included; `journey-email-templates` alone runs ~50; `data-designer` runs **1–2** templates because each costs 1 + tasks + fields calls under the script's own per-invocation spawn budget — sizing rationale in the @@ -556,6 +566,17 @@ stop re-invoking — those failures are deterministic; report the failed keys wi their recorded errors instead of looping. A run that documents 0 with an EMPTY `failures` list and `budgetExhausted: true` (the designer doc-mode's spawn budget ran out mid-template) is progress — its doc grew on disk — so re-invoke. +The script also enforces a **within-run** limit and names it in the summary's +`aborted` field (absent when the run did not stop early; the fields above keep +their meanings): `aborted.reason: "consecutive-failures"` — five entries in a row +ended in a `failed` mark and the loop stopped with those marks standing; the +untried entries are still queued, so re-invoke once, and a second run that aborts +the same way on the same keys is the stop rule above firing (check the recorded +`describeCommand` and id field before anything else). `aborted.reason: "auth"` — +the CLI could not obtain a token: the in-flight entry was NOT marked and keeps its +status (any `failures` listed are real describe failures from EARLIER in the same +run and stand); have the user run `gs-admin login`, then re-invoke, and never +count that run as a stop-rule sample. On `--deep` runs add `--upgrade` (a failed stub keeps depth `metadata`, stays in the upgrade queue, and is retried the same way). Email templates, journey programs, and data designers run through this same batch script — it writes compact docs for the diff --git a/plugins/gs-superadmin/skills/setup/references/document-domain-notes.md b/plugins/gs-superadmin/skills/setup/references/document-domain-notes.md index 011608c..656f479 100644 --- a/plugins/gs-superadmin/skills/setup/references/document-domain-notes.md +++ b/plugins/gs-superadmin/skills/setup/references/document-domain-notes.md @@ -139,7 +139,10 @@ the doc (skips what is captured, retries what failed). Size `--limit` at **1–2 templates per invocation and keep re-invoking until `moreRemaining: false`; the stderr progress line shows `(spawns used/budget)`. A budget-cut run reports `documented: 0` with an EMPTY `failures` list — that is progress, not the stop -rule's "same failures twice" signal; the stop rule keys on named failures only. A +rule's "same failures twice" signal; the stop rule keys on named failures only. +A run carrying `aborted` has the same shape and is NOT progress: the token died +(`aborted.reason: "auth"` — log in, then re-invoke) or five entries in a row failed +(`"consecutive-failures"`) — the rule is setup Phase 5's stop-rule paragraph. A template with a RETRYABLE failure (timeout, transport, unexpected output) after every item was attempted is marked `failed` with the count (its doc stays on disk and still yields its summary rows) and the stop rule applies as usual; a field the diff --git a/plugins/gs-superadmin/skills/setup/references/document-mechanics.md b/plugins/gs-superadmin/skills/setup/references/document-mechanics.md index daf4c43..5f4a9ec 100644 --- a/plugins/gs-superadmin/skills/setup/references/document-mechanics.md +++ b/plugins/gs-superadmin/skills/setup/references/document-mechanics.md @@ -64,6 +64,10 @@ node ".gs-superadmin/plugin/scripts/manifest.mjs" next --manifest /_manife If the describe command fails for an asset: `… mark --manifest /_manifest.json --key --status failed --error ""` — then continue with the next asset. +Exception: a failure carrying the CLI's re-login instruction (`Run gs-admin login to +re-authenticate`) is the session's token, not the asset — mark nothing, have the user +log in, and retry the same asset (the batch script draws the same line: its summary +reports `aborted.reason: "auth"` and leaves the entry's status untouched). ## §4 Budget-report shapes (after Phase 5 hits the budget limit) diff --git a/plugins/gs-superadmin/test/capture.mjs b/plugins/gs-superadmin/test/capture.mjs index 77b594f..0333faf 100644 --- a/plugins/gs-superadmin/test/capture.mjs +++ b/plugins/gs-superadmin/test/capture.mjs @@ -62,6 +62,7 @@ try { { namespace: "rules-engine", aliases: ["re"] }, { namespace: "journey", aliases: ["jo"] }, { namespace: "data-management", aliases: ["dm"] }, + { namespace: "connectors", aliases: ["cn"] }, ], commands: [ // The capture surface the skills actually use: list pages, per-item @@ -69,6 +70,12 @@ try { { path: "rules-engine rules list", shortPath: "re r list", mutating: false, actionKey: "list-rules", endpoints: [{ method: "POST", path: "/v1/rulesengine/rules/list" }] }, { path: "rules-engine rules describe", shortPath: "re r describe", mutating: false, actionKey: "describe-rule" }, + // F-456: describe-shaped by actionKey ONLY — the resolved-path verb is a + // noun ("chain"); capture inherits the admit through the shared + // predicate (doc-lib isDescribeRead). The trimmed twin has no actionKey. + { path: "connectors chain", shortPath: "cn chain", mutating: false, actionKey: "describe-job-chain", + endpoints: [{ method: "GET", path: "/v1/connectors/chains/{{id}}" }] }, + { path: "connectors chain-trimmed", shortPath: "cn chain-trimmed", mutating: false }, // List-shaped by actionKey only — the resolved-path verb is "templates". { path: "journey email templates", shortPath: "jo e templates", mutating: false, actionKey: "list-email-templates", endpoints: [{ method: "GET", path: "/v1/email/templates" }] }, @@ -207,6 +214,10 @@ try { check("gate: list-shaped by summary prong admitted (dm deps config — domain-candidates parity)", r.code === 0, r); r = cap(["--out", join(TMP, "desc.json"), "--bin", FAKE, "--", "gs-admin", "--json", "re", "r", "describe", "--id", "r-1"]); check("gate: describe-shaped read admitted", r.code === 0, r); + r = cap(["--out", join(TMP, "chain.json"), "--bin", FAKE, "--", "gs-admin", "--json", "cn", "chain", "--id", "ch-1"]); + check("gate F-456: describe-shaped by actionKey admitted (cn chain — the verb is a noun; capture inherits the shared predicate)", r.code === 0, r); + r = cap(["--out", join(TMP, "x.json"), "--bin", FAKE, "--", "gs-admin", "--json", "cn", "chain-trimmed", "--id", "ch-1"]); + check("gate F-456: the trimmed shape (no actionKey) still refuses on the trailing word", r.code === 1 && /not a capture-shaped read/.test(r.stderr), r); // ── Refusals ─────────────────────────────────────────────────────────────── r = cap(["--out", join(TMP, "x.json"), "--bin", FAKE, "--", "gs-admin", "--json", "re", "r", "delete", "--id", "r-1"]); diff --git a/plugins/gs-superadmin/test/describe-batch.mjs b/plugins/gs-superadmin/test/describe-batch.mjs index 6229123..67c2d3e 100644 --- a/plugins/gs-superadmin/test/describe-batch.mjs +++ b/plugins/gs-superadmin/test/describe-batch.mjs @@ -123,6 +123,18 @@ writeFileSync( { path: "data-designer templates describe", shortPath: "dd t describe", mutating: false, flags: [{ flag: "--template-id" }, { flag: "--template-name" }, { flag: "--task-id" }, { flag: "--pivot-column" }, { flag: "--field" }], endpoints: [{ method: "GET", path: "/v1/bionicreporting/designTemplates/{{templateId}}" }, { method: "GET", path: "/v1/bionicreporting/designTemplates/{{templateId}}/tasks" }] }, + // F-456 (DB-2): a per-item read whose PATH ends in a noun — admitted on + // its actionKey (the catalog's own statement of what the action is), + // never on the trailing word. Shape from the 1.0.9 catalog. + { path: "connectors chain", shortPath: "cn chain", mutating: false, actionKey: "describe-job-chain", + summary: "Describe a job execution chain set", endpoints: [{ method: "GET", path: "/v1/connectors/chains/{{id}}" }] }, + // …the hand-trimmed shape (path/shortPath/mutating only): no actionKey + // to read, so the trailing-word fallback decides — and refuses "chain". + { path: "connectors chain-trimmed", shortPath: "cn chain-trimmed", mutating: false }, + // …and a describe-shaped actionKey over a write endpoint: the endpoint + // gate stays independent of the actionKey admit. + { path: "connectors chain-sneaky", shortPath: "cn chain-sneaky", mutating: false, actionKey: "describe-chain-sneaky", + endpoints: [{ method: "PUT", path: "/v1/connectors/chains/{{id}}" }] }, ], }) ); @@ -137,6 +149,33 @@ writeFileSync( "const a = process.argv;", "const at = (f) => { const i = a.indexOf(f); return i > -1 ? a[i + 1] : undefined; };", "const id = at('--id') ?? at('--name');", + // DB-3 / DB-4 (issue #13, F-458) run controls, all env-driven and off by + // default. FAKE_SPAWN_LOG: one line per spawn — the suite counts spawns + // from it, never from the summary. FAKE_FAIL_PATTERN: an F/S string + // indexed by spawn number modulo its length; F fails that spawn with a + // transport-shaped error (exit 1, no re-login sentence). FAKE_AUTH_AFTER=N: + // every spawn past the Nth dies the way the CLI's shared handler prints a + // thrown auth error (`Error: ` on stderr, exit 1 — + // dist/commands/base.js BaseCommand.catch at 1.0.9); FAKE_AUTH_LITERAL + // picks which of the four dist/core/auth/index.js throws (default 3, the + // finding's half-life literal). FAKE_WARN_AUTH=1: the phrase on stderr + // under a NORMAL exit 0 — the ruling's negative arm (2026-09-14: only a + // failed spawn is classified). + "const AUTH_LITERALS = [", + " 'No stored token found. Run `gs-admin login` to authenticate.',", + " 'Access token has expired and no refresh token is available. Run `gs-admin login` to re-authenticate.',", + " 'Token expired and silent refresh failed (GET https://acme.gainsightcloud.com/v1/oauth/apps returned 401). Run `gs-admin login` to re-authenticate.',", + " 'Token refresh failed (400). Run `gs-admin login` to re-authenticate.',", + "];", + // The spawn number comes from a one-byte-per-spawn sibling counter file + // (O(1): its size IS the count); the log itself keeps the argv lines. A + // control set without the log would silently no-op — refused loudly. + "let spawnN = 0;", + "if ((process.env.FAKE_FAIL_PATTERN || process.env.FAKE_AUTH_AFTER !== undefined) && !process.env.FAKE_SPAWN_LOG) { console.error('fake: FAKE_FAIL_PATTERN / FAKE_AUTH_AFTER need FAKE_SPAWN_LOG (the spawn counter) — refusing'); process.exit(97); }", + "if (process.env.FAKE_SPAWN_LOG) { const fs0 = await import('node:fs'); fs0.appendFileSync(process.env.FAKE_SPAWN_LOG, a.slice(2).join(' ') + '\\n'); fs0.appendFileSync(process.env.FAKE_SPAWN_LOG + '.n', '.'); spawnN = fs0.statSync(process.env.FAKE_SPAWN_LOG + '.n').size; }", + "if (process.env.FAKE_AUTH_AFTER !== undefined && spawnN > Number(process.env.FAKE_AUTH_AFTER)) { console.error('Error: ' + AUTH_LITERALS[Number(process.env.FAKE_AUTH_LITERAL ?? '3') - 1]); process.exit(1); }", + "if (process.env.FAKE_FAIL_PATTERN) { const p = process.env.FAKE_FAIL_PATTERN; if (p[(spawnN - 1) % p.length] === 'F') { console.error('boom: simulated transport failure on spawn ' + spawnN); process.exit(1); } }", + "if (process.env.FAKE_WARN_AUTH === '1') console.error('Error: ' + AUTH_LITERALS[2]);", "if (id === 'r-fail') { console.error('boom: simulated describe failure'); process.exit(1); }", // W9 designer branch — the three levels of `dd t describe` at pin 1.0.8, // fixtures under $FAKE_DD (a JSON file: { templates: { : { tasks:[summary @@ -923,7 +962,7 @@ check( resetLog(); r = dd(["--limit", "1", "--spawn-budget", "3"]); const cut = fenceOf("dd-1.md"); - check("W9 budget: the run stops at the budget — nothing marked, entry still stale, budgetExhausted + moreRemaining true, 3 spawns", r.code === 0 && r.json?.documented === 0 && r.json?.drilldowns?.budgetExhausted === true && r.json?.moreRemaining === true && r.json?.drilldowns?.spawnsUsed === 3 && invOf("data-designer/dd-1").status === "stale", r.json); + check("W9 budget: the run stops at the budget — nothing marked, entry still stale, budgetExhausted + moreRemaining true, 3 spawns — and NO `aborted` (distinguishable by field from a DB-3/DB-4 abort)", r.code === 0 && r.json?.documented === 0 && r.json?.drilldowns?.budgetExhausted === true && r.json?.moreRemaining === true && r.json?.drilldowns?.spawnsUsed === 3 && invOf("data-designer/dd-1").status === "stale" && r.json?.aborted === undefined, r.json); check("W9 budget: the partial doc is on disk and SAYS so — t1 ok, t2 pending, one field ok, provenance INCOMPLETE", cut.payload.data._kb.tasks.t1.status === "ok" && cut.payload.data._kb.tasks.t2.status === "pending" && Object.values(cut.payload.data._kb.fields.t1).filter((f) => f.status === "ok").length === 1 && /INCOMPLETE — resumes on the next describe-batch run/.test(cut.text), cut.text.split("\n")[2]); check("W9 budget: stderr progress line reports spawns used against the budget", /\(spawns 3\/3\)/.test(r.stderr), r.stderr); resetLog(); @@ -966,7 +1005,7 @@ check( const dd6calls = argLines().filter((a) => a.includes("dd-6") && a.includes("--field")).map((a) => a[a.indexOf("--field") + 1]); check("W9 fallback: an UNKNOWN trailing group is tried whole first, then shape-stripped on the not-found refusal; the resolved spelling is recorded", e6.status === "documented" && isDeepStrictEqual(dd6calls.slice(0, 2), ["Growth (new_fn)", "Growth"]) && fb.payload.data._kb.fields.t1["Growth (new_fn)"].status === "ok" && fb.payload.data._kb.fields.t1["Growth (new_fn)"].spelling === "Growth", { dd6calls, f: fb.payload.data._kb.fields.t1 }); check("W9 fallback: a label whose own parenthesis resolves whole is never stripped; a known suffix is tried stripped first and whole on refusal", isDeepStrictEqual(dd6calls.slice(2), ["Rollup (v2)", "Total", "Total (SUM)"]) && fb.payload.data._kb.fields.t1["Rollup (v2)"].spelling === undefined && fb.payload.data._kb.fields.t1.Total.status === "ok" && fb.payload.data._kb.fields.t1.Total.spelling === "Total (SUM)", { dd6calls, f: fb.payload.data._kb.fields.t1 }); - check("W9 failure: the batch continued past the failures and the queue re-offers the retryable ones", r.json?.moreRemaining === true && r.json?.failed === 3, r.json); + check("W9 failure: the batch continued past the failures and the queue re-offers the retryable ones — three failures, none consecutive past two, no abort", r.json?.moreRemaining === true && r.json?.failed === 3 && r.json?.aborted === undefined, r.json); resetLog(); r = dd(["--limit", "9"]); check("W9 failure: the retry resumes dd-2 (fingerprint unchanged), re-spawns ONLY its retryable item (template + 1 task) and SKIPS the permanent field; dd-4/dd-5 fail again at their gates without drilldowns", r.json?.drilldowns?.resumedEntries === 1 && r.json?.drilldowns?.thisRun?.fieldsSkippedUnresolvable === 1 && argLines().filter((a) => a.includes("--task-id")).length === 1 && r.json?.failed === 3, { drilldowns: r.json?.drilldowns, calls: argLines() }); @@ -993,6 +1032,221 @@ check( manifest("upsert-batch", ["--file", otherList, "--domain", "designs-x", "--id-field", "templateId", "--name-field", "name"]); r = batch(["--domain", "designs-x", "--out-dir", join(ROOT, "acme-sbx", "designs-x"), "--bin", FAKE, "--command", "gs-admin --json dd t describe --template-id {id}"], ENV); check("W9 mode: a domain NOT named data-designer whose command resolves to `dd t describe` still gets the designer doc-mode", r.json?.docMode === "designer" && r.json?.documented === 1 && !!fenceOf(join("..", "designs-x", "dd-1.md")).payload.data._kb, r.json); + + // ── DB-3 / DB-4 in designer mode: the abort counter is per ENTRY outcome, + // and an auth death inside a drilldown aborts with the entry unmarked ──── + { + const SPAWN_LOG = join(ROOT, "dd-spawn-log.txt"); + // dd-7: five tasks, every drilldown fails on transport — ONE failed entry + // outcome, whatever the spawn count (issue #13: the counter is per + // top-level entry, never per drilldown spawn). + const fx7 = JSON.parse(JSON.stringify(fx)); + fx7.templates["dd-7"] = { + name: "Acme Five Broken", + tasks: ["a", "b", "c", "d", "e"].map((t) => ({ taskId: `t-${t}`, taskName: t, taskType: "mdaExtract", _parents: "", _object: "company", _connType: "MDA", _fieldCount: 0, _filterCount: 0, _groupByCount: 0 })), + failTasks: ["t-a", "t-b", "t-c", "t-d", "t-e"], details: {}, fields: {}, + }; + writeFileSync(FX, JSON.stringify(fx7)); + const dd7List = join(ROOT, "dd-7.json"); + writeFileSync(dd7List, JSON.stringify({ data: [{ templateId: "dd-7", name: "Acme Five Broken" }] })); + manifest("upsert-batch", ["--file", dd7List, "--domain", "data-designer", "--id-field", "templateId", "--name-field", "name"]); + const resetSpawns = () => { rmSync(SPAWN_LOG, { force: true }); rmSync(SPAWN_LOG + ".n", { force: true }); }; + resetLog(); resetSpawns(); + r = dd(["--limit", "1", "--statuses", "pending"]); + check("#13 designer: five failed DRILLDOWNS are one failed entry outcome — marked failed, no abort (the counter is per entry, never per spawn)", + r.json?.failed === 1 && r.json?.aborted === undefined && r.json?.drilldowns?.thisRun?.tasksFailed === 5 && invOf("data-designer/dd-7").status === "failed", r.json); + // dd-1 again, the token dying on the THIRD spawn (template ok, t1 ok, the + // first field detail dies): nothing marked, the composite records no + // false task/field outcome, the summary names the auth abort. + writeFileSync(FX, JSON.stringify(fx)); + manifest("mark", ["--key", "data-designer/dd-1", "--status", "stale"]); + rmSync(join(DD_OUT, "dd-1.md"), { force: true }); + resetLog(); resetSpawns(); + r = dd(["--limit", "1", "--statuses", "stale"], { FAKE_SPAWN_LOG: SPAWN_LOG, FAKE_AUTH_AFTER: "2" }); + const partial = fenceOf("dd-1.md"); + check("F-458 designer: an auth death inside a drilldown aborts the run — reason auth, after 1, entry UNMARKED (still stale), 3 spawns, budget not the signal", + r.code === 0 && r.json?.aborted?.reason === "auth" && r.json?.aborted?.after === 1 && r.json?.documented === 0 && r.json?.failed === 0 && + invOf("data-designer/dd-1").status === "stale" && r.json?.drilldowns?.spawnsUsed === 3 && r.json?.drilldowns?.budgetExhausted === false && + r.json?.moreRemaining === true, + { json: r.json, entry: invOf("data-designer/dd-1") }); + // …and the same death one level UP, on the template spawn itself (the + // review round's sibling): the same top-level record, one spawn, nothing + // on disk for the entry, nothing marked. + { + const before = existsSync(join(DD_OUT, "dd-1.md")) ? readFileSync(join(DD_OUT, "dd-1.md"), "utf8") : null; + resetLog(); resetSpawns(); + const rr = dd(["--limit", "1", "--statuses", "stale"], { FAKE_SPAWN_LOG: SPAWN_LOG, FAKE_AUTH_AFTER: "0" }); + const after = existsSync(join(DD_OUT, "dd-1.md")) ? readFileSync(join(DD_OUT, "dd-1.md"), "utf8") : null; + check("F-458 designer: an auth death on the TEMPLATE spawn aborts the same way — reason auth, after 1, 1 spawn, entry still stale, the partial composite untouched", + rr.json?.aborted?.reason === "auth" && rr.json?.aborted?.after === 1 && rr.json?.drilldowns?.spawnsUsed === 1 && rr.json?.failed === 0 && + invOf("data-designer/dd-1").status === "stale" && after === before, { json: rr.json }); + } + check("F-458 designer: the composite on disk carries NO outcome from the token death — t1 ok, its first field still pending (never `failed`), t2 pending, provenance INCOMPLETE", + partial.payload.data._kb.tasks.t1.status === "ok" && partial.payload.data._kb.fields.t1.ARR.status === "pending" && + partial.payload.data._kb.tasks.t2.status === "pending" && /INCOMPLETE/.test(partial.text), + partial.payload.data._kb); + // …and the resume is unchanged: the next run picks the composite up. + resetLog(); resetSpawns(); + r = dd(["--limit", "1", "--statuses", "stale"]); + check("F-458 designer: the next invocation resumes from the partial composite and completes (resumedEntries 1, documented)", + r.json?.documented === 1 && r.json?.drilldowns?.resumedEntries === 1 && r.json?.aborted === undefined && invOf("data-designer/dd-1").status === "documented", r.json); + } +} + +// ── DB-2 (F-456): the read-shape gate reads the catalog's actionKey ────────── +// `cn chain` is a per-item describe whose PATH ends in a noun; the trailing- +// word predicate refused it — and, since capture.mjs composes the same gate, +// both sanctioned capture paths refused it, leaving no legal route to +// document the domain (the finding's third note). The shared predicate now +// admits a describe-shaped actionKey on its own merits, keeps the trailing- +// word rule for hand-trimmed catalogs (no actionKey to read), and the +// endpoint gate stays independent of either admit. +{ + const chainList = join(ROOT, "chains-cn.json"); + writeFileSync(chainList, JSON.stringify({ data: [{ chainId: "ch-1", name: "Nightly Load Chain" }] })); + manifest("upsert-batch", ["--file", chainList, "--domain", "connectors-chains", "--id-field", "chainId", "--name-field", "name"]); + const CN_OUT = join(ROOT, "acme-sbx", "connectors-chains"); + let r = batch(["--domain", "connectors-chains", "--out-dir", CN_OUT, "--bin", FAKE, "--command", "gs-admin --json cn chain --id {id}"]); + check("F-456: `cn chain --id {id}` admitted on its actionKey (describe-job-chain) though the path's trailing word is a noun", + r.code === 0 && r.json?.documented === 1 && existsSync(join(CN_OUT, "ch-1.md")), r); + manifest("mark", ["--key", "connectors-chains/ch-1", "--status", "stale"]); + r = batch(["--domain", "connectors-chains", "--out-dir", CN_OUT, "--bin", FAKE, "--command", "gs-admin --json cn chain-trimmed --id {id}"]); + check("F-456: a hand-trimmed entry (no actionKey) still decides on the trailing word — `chain-trimmed` refused", + r.code === 1 && /not a describe-shaped read/.test(r.stderr) && /action "chain-trimmed"/.test(r.stderr), r); + r = batch(["--domain", "connectors-chains", "--out-dir", CN_OUT, "--bin", FAKE, "--command", "gs-admin --json cn chain-sneaky --id {id}"]); + check("F-456: a describe-shaped actionKey over a PUT endpoint is still refused by the endpoint gate", r.code === 1 && /declares a PUT endpoint/.test(r.stderr), r); + check("F-456: nothing was marked by the two refusals (the gate precedes every spawn and every write)", + JSON.parse(readFileSync(M, "utf8")).inventory["connectors-chains/ch-1"].status === "stale", null); + + // The catalog sweep the finding's Expected asks for: every command the + // SHIPPED catalog declares as a describe-* action goes through the shared + // predicate, so a refused per-item read is named HERE, at adoption time, + // never discovered mid-run. Source: the bundled reference/catalog.json (a + // verbatim copy of data/catalog.json — build-plugin-gs-superadmin.mjs; CI + // diffs the copy). + const { isDescribeRead, READ_VERB_EXACT, DESCRIBE_SHAPE_RE } = await import(pathToFileURL(join(SCRIPTS, "doc-lib.mjs")).href); + const catalog = JSON.parse(readFileSync(join(SCRIPTS, "..", "reference", "catalog.json"), "utf8")); + const tail = (c) => String(c.path).trim().split(/\s+/).pop(); // the gate's own derivation (assertReadOnlyCommand) + const admitted = catalog.commands.filter((c) => isDescribeRead(tail(c), c)); + const refusedDescribes = catalog.commands.filter((c) => DESCRIBE_SHAPE_RE.test(c.actionKey ?? "") && !isDescribeRead(tail(c), c)); + check(`F-456 sweep (source: the ${catalog.meta.cliVersion} catalog's actionKeys): every describe-* action is admitted by the gate predicate`, + refusedDescribes.length === 0, refusedDescribes.map((c) => `${c.shortPath} (${c.actionKey})`)); + const admittedByKeyOnly = admitted.filter((c) => !DESCRIBE_SHAPE_RE.test(tail(c)) && !READ_VERB_EXACT.has(tail(c))); + check("F-456 sweep: the actionKey admit reaches exactly the commands the trailing word refused — cn chain (the finding) and re r execution (the sibling the finding did not list)", + isDeepStrictEqual(admittedByKeyOnly.map((c) => c.shortPath).sort(), ["cn chain", "re r execution"]), admittedByKeyOnly.map((c) => c.shortPath)); + const unsafe = admitted.filter((c) => c.mutating || (c.endpoints ?? []).some((e) => /^(PUT|DELETE|PATCH)$/.test(e.method))); + check("F-456 sweep: nothing the predicate admits is catalog-mutating or declares a write endpoint (the safety direction)", unsafe.length === 0, unsafe.map((c) => c.shortPath)); + // Adoption-time tripwire: the admitted set at this pin, as data. A pin + // that adds, renames or drops a per-item read changes this list and is + // named here — the delta question the upstream watcher asks cannot see a + // baseline miss (F-456's provenance note); this list is the baseline. + const ADMITTED_AT_PIN = [ + "cn chain", "dd t describe", "dm dd describe", "dm o describe", "dm o list-and-describe", "jo dd get", "jo e template", + "jo p describe", "jo p src describe", "jo s get", "re c describe", "re c event-curl", "re r describe", + "re r describe-external-action", "re r event-curl", "re r events", "re r execution", "re r list-and-describe", + "re r s3-tasks", "re r schedules", "re r topics", "rp describe", "runtime describe-asset-schema", + "runtime describe-asset-type", "sc measures", + ]; + check(`F-456 sweep: the admitted set at ${catalog.meta.cliVersion} is exactly the pinned ${ADMITTED_AT_PIN.length} (a changed set at a new pin is named here)`, + isDeepStrictEqual(admitted.map((c) => c.shortPath).sort(), ADMITTED_AT_PIN), admitted.map((c) => c.shortPath).sort()); +} + +// ── DB-3 (issue #13) + DB-4 (F-458): the within-run abort ──────────────────── +// One ADDITIVE summary field, `aborted: { reason, after, lastError }`, absent +// on a run that did not abort; budgetExhausted / moreRemaining / failures +// keep their shapes and meanings (the setup skill branches on them). Spawns +// are counted from the fake CLI's own log, never inferred from the summary. +{ + const LOG = join(ROOT, "spawn-log.txt"); + const spawns = () => (existsSync(LOG) ? readFileSync(LOG, "utf8").split("\n").filter(Boolean).length : 0); + const fresh = (domain, n) => { + rmSync(LOG, { force: true }); rmSync(LOG + ".n", { force: true }); + const f = join(ROOT, `${domain}.json`); + writeFileSync(f, JSON.stringify({ data: Array.from({ length: n }, (_, i) => ({ ruleId: `${domain}-${i + 1}`, name: `Rule ${i + 1}` })) })); + manifest("upsert-batch", ["--file", f, "--domain", domain, "--id-field", "ruleId", "--name-field", "name"]); + return (env, extra = []) => + batch(["--domain", domain, "--out-dir", join(ROOT, "acme-sbx", domain), "--bin", FAKE, "--command", DESCRIBE, ...extra], { FAKE_SPAWN_LOG: LOG, ...env }); + }; + const statusCounts = (domain) => { + const inv = JSON.parse(readFileSync(M, "utf8")).inventory; + const c = {}; + for (const k of Object.keys(inv)) if (k.startsWith(`${domain}/`)) c[inv[k].status] = (c[inv[k].status] ?? 0) + 1; + return c; + }; + + // Every describe fails: exactly 5 spawns, then the loop stops. + let run = fresh("re-abort-all", 8); + let r = run({ FAKE_FAIL_PATTERN: "F" }); + check("#13: every describe failing stops the domain after exactly 5 spawns (8 entries selected)", r.code === 0 && spawns() === 5 && r.json?.selected === 8, { code: r.code, spawns: spawns(), json: r.json }); + check("#13: the summary names the abort — aborted { reason: consecutive-failures, after: 5, lastError }", + r.json?.aborted?.reason === "consecutive-failures" && r.json?.aborted?.after === 5 && /simulated transport failure/.test(r.json?.aborted?.lastError ?? ""), r.json?.aborted); + check("#13: the five marks stand exactly as written; the three untried entries are still pending", + isDeepStrictEqual(statusCounts("re-abort-all"), { failed: 5, pending: 3 }) && r.json?.failed === 5 && r.json?.failures?.length === 5 && r.json?.documented === 0, statusCounts("re-abort-all")); + check("#13: moreRemaining stays true; budgetExhausted is not the signal (no drilldowns block outside designer mode)", + r.json?.moreRemaining === true && r.json?.budgetExhausted === undefined && r.json?.drilldowns === undefined, r.json); + check("#13: stderr says so — never silent", /\[describe-batch\] ABORTED after 5 entries \(consecutive-failures\)/.test(r.stderr), r.stderr); + // The boundary: 4 failures then a success runs to completion, and the count RESETS. + run = fresh("re-boundary", 8); + r = run({ FAKE_FAIL_PATTERN: "FFFFS" }); + check("#13 boundary: F F F F S F F F — no abort, all 8 spawned (four in a row never trips it; the success resets the count)", + r.code === 0 && spawns() === 8 && r.json?.aborted === undefined && r.json?.documented === 1 && r.json?.failed === 7, { spawns: spawns(), json: r.json }); + // …and the off-by-one the other way: the 5th consecutive failure aborts + // even when the batch would have ended one entry later. + run = fresh("re-fifth", 6); + r = run({ FAKE_FAIL_PATTERN: "FFFFFS" }); + check("#13 boundary: the 5th consecutive failure aborts — the 6th entry (a success) is never spawned", + spawns() === 5 && r.json?.aborted?.after === 5 && r.json?.failed === 5 && r.json?.documented === 0, { spawns: spawns(), json: r.json }); + // The summary's domainProgress is re-read at the abort (review round): on + // an --upgrade run the loop can stop between progress ticks, and a stale + // snapshot would disagree with the summary's own `failed`. S F F F F F on a + // 10-stub domain: entry 1 full, 2–6 fail, abort at the 6th — the tick fired + // at the 5th, before the fifth failed mark. + { + rmSync(LOG, { force: true }); rmSync(LOG + ".n", { force: true }); + const f = join(ROOT, "re-abort-upgrade.json"); + writeFileSync(f, JSON.stringify({ data: Array.from({ length: 10 }, (_, i) => ({ ruleId: `up-${i + 1}`, name: `Up ${i + 1}` })) })); + manifest("upsert-batch", ["--file", f, "--domain", "re-abort-upgrade", "--id-field", "ruleId", "--name-field", "name"]); + const UP_OUT = join(ROOT, "acme-sbx", "re-abort-upgrade"); + manifest("stub", ["--file", f, "--domain", "re-abort-upgrade", "--id-field", "ruleId", "--name-field", "name", "--out-dir", UP_OUT]); + const rr = batch(["--domain", "re-abort-upgrade", "--out-dir", UP_OUT, "--bin", FAKE, "--upgrade", "--command", DESCRIBE], { FAKE_SPAWN_LOG: LOG, FAKE_FAIL_PATTERN: "SFFFFF" }); + check("#13 --upgrade: the abort's domainProgress is fresh — failed 5 (not the pre-abort tick's 4), full 1, total 10, after 6", + rr.json?.aborted?.after === 6 && rr.json?.failed === 5 && rr.json?.domainProgress?.failed === 5 && rr.json?.domainProgress?.full === 1 && rr.json?.domainProgress?.total === 10, + { json: rr.json }); + } + // Resumption is unchanged: the next invocation documents the untried and + // retries the failed (issue #13: "nothing about resumption changes"). + rmSync(LOG, { force: true }); rmSync(LOG + ".n", { force: true }); + r = batch(["--domain", "re-abort-all", "--out-dir", join(ROOT, "acme-sbx", "re-abort-all"), "--bin", FAKE, "--command", DESCRIBE], { FAKE_SPAWN_LOG: LOG }); + check("#13 resume: the next invocation documents the three untried and retries the five failed — 8 spawns, no abort, all documented", + r.code === 0 && spawns() === 8 && r.json?.aborted === undefined && r.json?.documented === 8 && isDeepStrictEqual(statusCounts("re-abort-all"), { documented: 8 }), { spawns: spawns(), json: r.json }); + + // An auth death: the FIRST sighting aborts and NOTHING is marked. + run = fresh("re-auth", 5); + r = run({ FAKE_AUTH_AFTER: "2" }); + check("F-458: the CLI's re-login literal after 2 successes — 3 spawns, aborted { reason: auth, after: 3, lastError names the literal }", + r.code === 0 && spawns() === 3 && r.json?.aborted?.reason === "auth" && r.json?.aborted?.after === 3 && /Token expired and silent refresh failed/.test(r.json?.aborted?.lastError ?? ""), { spawns: spawns(), json: r.json }); + check("F-458: the in-flight entry keeps its status (pending), failures is EMPTY, failed 0 — an auth death is never a describe failure", + r.json?.failed === 0 && isDeepStrictEqual(r.json?.failures, []) && isDeepStrictEqual(statusCounts("re-auth"), { documented: 2, pending: 3 }), statusCounts("re-auth")); + check("F-458: domainProgress reads two documented of five; moreRemaining true", r.json?.domainProgress?.documented === 2 && r.json?.domainProgress?.total === 5 && r.json?.moreRemaining === true, r.json); + check("F-458: stderr names the auth abort", /ABORTED after 3 entries \(auth\)/.test(r.stderr), r.stderr); + // The three sibling literals the finding did not list: every auth-path + // throw in dist/core/auth/index.js ends with the same re-login sentence. + for (const [lit, label] of [["1", "no stored token"], ["2", "expired, no refresh token"], ["4", "refresh POST refused"]]) { + run = fresh(`re-auth-lit${lit}`, 2); + r = run({ FAKE_AUTH_AFTER: "0", FAKE_AUTH_LITERAL: lit }); + check(`F-458 sibling: literal ${lit} (${label}) aborts as auth on the first spawn with nothing marked`, + spawns() === 1 && r.json?.aborted?.reason === "auth" && r.json?.aborted?.after === 1 && r.json?.failed === 0 && isDeepStrictEqual(statusCounts(`re-auth-lit${lit}`), { pending: 2 }), { spawns: spawns(), json: r.json }); + } + // The auth check precedes the consecutive count: 4 real failures, then the token dies. + run = fresh("re-auth-after-fails", 8); + r = run({ FAKE_FAIL_PATTERN: "FFFFS", FAKE_AUTH_AFTER: "4" }); + check("F-458: 4 real failures then an auth death — reason auth (not consecutive-failures), after 5, the 4 marks stand, the 5th entry unmarked", + spawns() === 5 && r.json?.aborted?.reason === "auth" && r.json?.aborted?.after === 5 && r.json?.failed === 4 && isDeepStrictEqual(statusCounts("re-auth-after-fails"), { failed: 4, pending: 4 }), { spawns: spawns(), json: r.json }); + // The ruling's negative arm (2026-09-14): the phrase on stderr under exit 0 + // is a success — only a FAILED spawn is classified. + run = fresh("re-auth-warn", 2); + r = run({ FAKE_WARN_AUTH: "1" }); + check("F-458 (ruled): the re-login phrase on stderr under exit 0 is NOT an auth death — both documented, no abort", r.code === 0 && r.json?.documented === 2 && r.json?.aborted === undefined, r.json); } // "Exactly one leading U+FEFF, never a global strip" — the global-regex mutant diff --git a/plugins/gs-superadmin/test/doc-lib-fixtures.mjs b/plugins/gs-superadmin/test/doc-lib-fixtures.mjs index 51d9c99..31d36e4 100644 --- a/plugins/gs-superadmin/test/doc-lib-fixtures.mjs +++ b/plugins/gs-superadmin/test/doc-lib-fixtures.mjs @@ -35,7 +35,7 @@ import { makeCommandResolver, listMdFiles, direntIsDirectory, replaceFileSync, removeFileSync, decode, htmlToText, templateTokens, compactProgramPayload, renderProgramDoc, renderTemplateDoc, STUB_MARKER, STUB_MARKER_RE, escapeRe, sleepMs, scanListEnvelope, decideEntryArray, entryDecisionReason, - parseDocJson, docMeta, docH1, NO_NAME, assertReadOnlyCommand, readKbIdentity, + parseDocJson, docMeta, docH1, NO_NAME, assertReadOnlyCommand, isDescribeRead, readKbIdentity, resolveRecordedDomains, recordedDomainsByPath, indexedElsewhere, RECORDED_LANES, laneTable, } from "../scripts/doc-lib.mjs"; // List-page envelope docs and the indexer payload live in the shared reader @@ -122,6 +122,38 @@ function check(label, cond, detail) { } } +// ── isDescribeRead — the read-shape predicate both spawn-capable scripts compose on (F-456) ── +// Decision points enumerated from the function (the unpinned-arm recipe): the +// actionKey clause fires only on a STRING actionKey that is describe-shaped +// (`describe` alone or `describe-…` — a segment boundary, never a prefix of a +// longer word); everything else falls to the trailing-word rule (describe +// shape or the exact allowlist). A non-describe actionKey never REFUSES a +// verb the trailing-word rule admits: at 1.0.9 twelve allowlisted commands +// carry keys like get-email-template, so "actionKey instead of the verb" +// would have refused them all. +{ + /** @type {Array<[string, string | undefined, {actionKey?: unknown} | null | undefined, boolean]>} */ + const arms = [ + ["describe-shaped actionKey, noun verb (cn chain)", "chain", { actionKey: "describe-job-chain" }, true], + ["actionKey exactly `describe`", "chain", { actionKey: "describe" }, true], + ["actionKey `described-x` is NOT describe-shaped (segment boundary)", "chain", { actionKey: "described-x" }, false], + ["non-describe actionKey, allowlisted verb (jo e template)", "template", { actionKey: "get-email-template" }, true], + ["non-describe actionKey, describe verb", "describe", { actionKey: "get-thing" }, true], + ["list actionKey, list verb", "list", { actionKey: "list-rules" }, false], + ["no actionKey, describe verb (trimmed catalog)", "describe", {}, true], + ["no actionKey, noun verb (trimmed catalog)", "chain", {}, false], + ["null entry, describe verb", "describe", null, true], + ["undefined entry, noun verb", "chain", undefined, false], + ["non-string actionKey falls to the verb — number, noun verb", "chain", { actionKey: 7 }, false], + ["non-string actionKey falls to the verb — number, describe verb", "describe", { actionKey: 7 }, true], + ["undefined verb, describe actionKey", undefined, { actionKey: "describe-x" }, true], + ["undefined verb, no actionKey", undefined, {}, false], + ["allowlisted verb `measures` under a get- actionKey", "measures", { actionKey: "get-scorecard-measures" }, true], + ["hyphenated allowlisted verb `s3-tasks`", "s3-tasks", { actionKey: "s3-tasks" }, true], + ]; + for (const [label, verb, cmd, want] of arms) check(`isDescribeRead: ${label} → ${want}`, isDescribeRead(verb, cmd) === want, { verb, cmd }); +} + // ── STUB_MARKER / STUB_MARKER_RE / escapeRe — the T-3 single source (DS-13) ── // The VALUE pin, hand-spelled at the export's home: single-sourcing makes // writer↔parser agreement structural, which also makes every flow-through From a594404c6d0ad6cc65b1a44898a1b8fb6c373ba4 Mon Sep 17 00:00:00 2001 From: BradleyDB <23158057+BradleyDB@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:47:14 -0700 Subject: [PATCH 2/5] handoff hb-20260914-01 (round-b-describe-loop) --- dev/FEEDBACK.md | 4 ++-- plugins/gs-superadmin/skills/dev-canary/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev/FEEDBACK.md b/dev/FEEDBACK.md index 5081f76..bdfb4ee 100644 --- a/dev/FEEDBACK.md +++ b/dev/FEEDBACK.md @@ -146,8 +146,8 @@ what the gate counts (F-444). The gate refuses both. Sections from F-360 on; the rule also reads the archive. -Under test: dev · hb-20260911-04 · 2026-09-11 -Blind spots (hb-20260911-04): post-merge close-out of PR 16 on dev: nothing new is under test; the F-449 arms were measured on the sandbox by the tester round (both CLEARED). Still unmeasured on any tenant: everything the next rounds bank (Session B onward, handoffs/ — local-only, untracked), and F-459 (documented entries with no doc_path) which is OPEN for Session C1 and reproducible only on the three July-crawled sandbox domains +Under test: round-b-describe-loop · hb-20260914-01 · 2026-09-14 +Blind spots (hb-20260914-01): two live arms this builder cannot run, banked in dev/VALIDATION.md (Session B sections): (1) F-456 — a deep-ingest of the connectors-chains lane through describe-batch with the RECORDED describe command, no --command, no manual fallback, no --normalize, plus one chain through the capture helper; (2) F-458 / #13 — one describe-batch run PAST the token half-life from a terminal, reading aborted.reason auth with failures empty in the summary and zero auth-failed entries in the manifest, then a resume after gs-admin login (optionally #13 live on a small domain with a mismatched describe, stopping after exactly 5 spawns). Also unwalked here: the setup skill (slash-only) and the fence-only edits in audit, deprecate, email-report, refresh — Session B-V walks them. Not a blind spot: the DB-1 shell side was measured on Windows PowerShell 5.1 on this machine, and every other claim is a committed fixture.