@event4u/agent-config 9.36.0 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +117 -2
- package/agents/templates/.ai-council.yml.example +31 -51
- package/dist/agent-src/commands/analyze/conformance.md +15 -4
- package/dist/agent-src/rules/session-canary.md +28 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +5 -0
- package/dist/agent-src/skills/check-refs/SKILL.md +5 -0
- package/dist/agent-src/skills/lint-skills/SKILL.md +5 -0
- package/dist/agent-src/skills/md-language-check/SKILL.md +5 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-SFGARMAB.js → chunk-2H5SE7DU.js} +24 -4
- package/dist/cli-delegate/{chunk-U7CMAIOC.js → chunk-3JUTT26I.js} +1 -1
- package/dist/cli-delegate/{chunk-JP53LNVT.js → chunk-HJSKTMIF.js} +6 -14
- package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
- package/dist/cli-delegate/{chunk-NR7HSN3P.js → chunk-NX5AXSLD.js} +3 -3
- package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
- package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
- package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +1037 -196
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +125 -89
- package/dist/install/install.mjs +22 -26
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1579 -616
- package/dist/scripts/_lib/agent_settings.js +39 -25
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +15 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/wizard.js +23 -3
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +6 -6
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +14 -9
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
- package/src/config/agent-settings.template.yml +6 -36
- package/src/config/ci-local-parity.yml +44 -0
- package/src/scripts/_lib/agent_settings.ts +39 -25
- package/src/scripts/_lib/environment_detector.ts +52 -1
- package/src/scripts/_lib/untrusted_content.test.ts +175 -0
- package/src/scripts/_lib/untrusted_content.ts +184 -0
- package/src/scripts/ai_council/config.ts +62 -24
- package/src/scripts/before_complete_hook.ts +39 -0
- package/src/scripts/check_bridge_derivation.ts +89 -3
- package/src/scripts/check_ci_local_parity.ts +74 -0
- package/src/scripts/check_references.ts +15 -1
- package/src/scripts/condense.ts +16 -2
- package/src/scripts/conformance_scan.ts +200 -9
- package/src/scripts/council_cli.ts +95 -3
- package/src/scripts/hook_manifest.yaml +12 -6
- package/src/scripts/hooks/block_no_verify.ts +82 -9
- package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
- package/src/scripts/probe_promissory_closing.ts +180 -0
- package/src/scripts/probe_session_canary.ts +180 -0
- package/src/scripts/schemas/skill.schema.json +42 -0
- package/src/scripts/skill_linter.ts +57 -2
- package/src/server/schemas/settings.ts +6 -14
|
@@ -41,6 +41,9 @@ import { isSyntheticPrompt } from "./_lib/prompt_shape.js";
|
|
|
41
41
|
import { classifyAuthorization, type GitOp } from "./git_authorization_hook.js";
|
|
42
42
|
import { BLOCK_OPS, commandOp } from "./hooks/block_unauthorized_git.js";
|
|
43
43
|
import { isVacuousOutput, isCiPoll, pendingCount } from "./before_complete_hook.js";
|
|
44
|
+
// Round 7 § 1.5 — the SAME predicate `turn-end-gate` refuses on, imported rather
|
|
45
|
+
// than reimplemented, so the measured rate and the gate cannot disagree.
|
|
46
|
+
import { detectCompletionClaim } from "./hooks/turn_end_gate_hook.js";
|
|
44
47
|
import { isEvaluationPrompt, isSelfScoped, preloadedVerdict } from "./hooks/evidence_independence.js";
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -57,7 +60,12 @@ import { isEvaluationPrompt, isSelfScoped, preloadedVerdict } from "./hooks/evid
|
|
|
57
60
|
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
58
61
|
|
|
59
62
|
export interface Violation {
|
|
60
|
-
check:
|
|
63
|
+
check:
|
|
64
|
+
| "language-pin"
|
|
65
|
+
| "git-authorization"
|
|
66
|
+
| "vacuous-evidence"
|
|
67
|
+
| "evidence-steering"
|
|
68
|
+
| "completion-claim";
|
|
61
69
|
session: string;
|
|
62
70
|
at: string;
|
|
63
71
|
detail: string;
|
|
@@ -78,6 +86,17 @@ export interface SessionReport {
|
|
|
78
86
|
user_turns: number;
|
|
79
87
|
assistant_turns: number;
|
|
80
88
|
violations: Violation[];
|
|
89
|
+
/**
|
|
90
|
+
* Round 7 § 6.2 — assistant turns that stood under a GERMAN pin, i.e. the only
|
|
91
|
+
* turns on which `language-pin` can fire at all. The rate was published over
|
|
92
|
+
* ALL assistant turns, which understates it by whatever fraction of a corpus is
|
|
93
|
+
* English-pinned: measured on round 7's own corpus, 1 655 of 2 354 (70 %), and
|
|
94
|
+
* the same 152 violations read 6.5 % on the old denominator and 9.2 % on this
|
|
95
|
+
* one — enough to move the band verdict from OUTSIDE to INSIDE.
|
|
96
|
+
*/
|
|
97
|
+
de_pin_turns: number;
|
|
98
|
+
/** First entry timestamp, so a reader can era-split without a second pass. */
|
|
99
|
+
first_at: string;
|
|
81
100
|
}
|
|
82
101
|
|
|
83
102
|
/**
|
|
@@ -134,7 +153,7 @@ export const OBSERVED_BAND = { low: 9.1, high: 39.2 } as const;
|
|
|
134
153
|
*/
|
|
135
154
|
export const BAND_MIN_TURNS = 1978;
|
|
136
155
|
|
|
137
|
-
export type BandVerdict = "inside" | "outside" | "corpus-too-small";
|
|
156
|
+
export type BandVerdict = "inside" | "outside" | "corpus-too-small" | "era-spanning";
|
|
138
157
|
|
|
139
158
|
/** Round-6 Phase 4.5 — the forward-capture record for one scan run. */
|
|
140
159
|
export interface RateRecord {
|
|
@@ -144,7 +163,29 @@ export interface RateRecord {
|
|
|
144
163
|
assistant_turns: number;
|
|
145
164
|
language_pin: number;
|
|
146
165
|
rate_pct: number;
|
|
147
|
-
/**
|
|
166
|
+
/**
|
|
167
|
+
* Round 7 § 6.2 / 6.1 — OPTIONAL, and not for convenience.
|
|
168
|
+
*
|
|
169
|
+
* `--record` appends one line per run to a series whose whole purpose is
|
|
170
|
+
* comparability over time, and every line written before round 7 carries none
|
|
171
|
+
* of these four. Declaring them required would make the type say something
|
|
172
|
+
* false about the records on disk, and the renderer already reads them
|
|
173
|
+
* defensively for exactly that reason. `analyse()` always populates them, so a
|
|
174
|
+
* fresh run is never partial — the optionality describes the archive, not the
|
|
175
|
+
* producer.
|
|
176
|
+
*/
|
|
177
|
+
de_pin_turns?: number;
|
|
178
|
+
/** The same numerator over `de_pin_turns`. Reported beside `rate_pct`, never instead. */
|
|
179
|
+
rate_pct_de_pin?: number;
|
|
180
|
+
/** The corpus straddles a carrier landing, so no band verdict. */
|
|
181
|
+
era_spanning?: boolean;
|
|
182
|
+
/** Which carrier, when `era_spanning` — empty otherwise. */
|
|
183
|
+
era_reason?: string;
|
|
184
|
+
/**
|
|
185
|
+
* `outside` is the falsifier firing; `corpus-too-small` and `era-spanning` are
|
|
186
|
+
* not readings — the first is about corpus size, the second about the corpus
|
|
187
|
+
* containing two different systems.
|
|
188
|
+
*/
|
|
148
189
|
band: BandVerdict;
|
|
149
190
|
/**
|
|
150
191
|
* `null` when the scanned store is NOT this repo's own — `measureDelivered` is
|
|
@@ -157,7 +198,49 @@ export interface RateRecord {
|
|
|
157
198
|
delivered_global_tokens: number | null;
|
|
158
199
|
}
|
|
159
200
|
|
|
160
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Round 7 § 6.1 — dates on which a carrier for a mechanised check LANDED.
|
|
203
|
+
*
|
|
204
|
+
* A rate pooled across one of these is not a reading about behaviour; it is a
|
|
205
|
+
* weighted average of two different systems. The band's falsifier is "a fourth
|
|
206
|
+
* PROJECT outside the band", and on round 7's first run it fired on the project
|
|
207
|
+
* that helped DEFINE the band — because the corpus straddled the language-pin
|
|
208
|
+
* carrier. 23.1 % before it, 0.0 % after, 6.5 % pooled, and the pooled figure was
|
|
209
|
+
* announced as the falsifier.
|
|
210
|
+
*
|
|
211
|
+
* Declared as data with provenance rather than inferred: nothing in a transcript
|
|
212
|
+
* records which carriers were bound while it was written.
|
|
213
|
+
*/
|
|
214
|
+
export const CARRIER_CHANGES: readonly { at: string; what: string }[] = [
|
|
215
|
+
{ at: "2026-08-07T00:00:00Z", what: "language-pin bound on user_prompt_submit (round 5)" },
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
/** True when the corpus spans a carrier change, i.e. two systems in one average. */
|
|
219
|
+
export function spansCarrierChange(firstAts: readonly string[]): { spans: boolean; what: string } {
|
|
220
|
+
const stamps = firstAts.filter((s) => s !== "").sort();
|
|
221
|
+
if (stamps.length === 0) {
|
|
222
|
+
return { spans: false, what: "" };
|
|
223
|
+
}
|
|
224
|
+
const lo = stamps[0] as string;
|
|
225
|
+
const hi = stamps[stamps.length - 1] as string;
|
|
226
|
+
for (const c of CARRIER_CHANGES) {
|
|
227
|
+
if (lo < c.at && hi >= c.at) {
|
|
228
|
+
return { spans: true, what: c.what };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return { spans: false, what: "" };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function bandVerdict(
|
|
235
|
+
rate_pct: number,
|
|
236
|
+
assistant_turns: number,
|
|
237
|
+
opts: { spansCarrier?: boolean } = {},
|
|
238
|
+
): BandVerdict {
|
|
239
|
+
// The era guard is checked FIRST and for the same reason the turn floor is: a
|
|
240
|
+
// verdict about corpus shape must not be reported as a verdict about behaviour.
|
|
241
|
+
if (opts.spansCarrier === true) {
|
|
242
|
+
return "era-spanning";
|
|
243
|
+
}
|
|
161
244
|
if (assistant_turns < BAND_MIN_TURNS) {
|
|
162
245
|
return "corpus-too-small";
|
|
163
246
|
}
|
|
@@ -181,6 +264,7 @@ export const CHECK_IDS: readonly CheckId[] = [
|
|
|
181
264
|
"git-authorization",
|
|
182
265
|
"vacuous-evidence",
|
|
183
266
|
"evidence-steering",
|
|
267
|
+
"completion-claim",
|
|
184
268
|
];
|
|
185
269
|
|
|
186
270
|
/**
|
|
@@ -207,6 +291,12 @@ export const CHECK_MEANINGS: Record<CheckId, string> = {
|
|
|
207
291
|
"evidence-steering":
|
|
208
292
|
"A self-commissioned evaluation whose prompt pre-loaded its own verdict, " +
|
|
209
293
|
"or a second self-scoped evaluation of the same subject within one turn.",
|
|
294
|
+
"completion-claim":
|
|
295
|
+
"An assistant turn claiming the work is complete while the last CI read in " +
|
|
296
|
+
"that session was NOT settled. Round 7 measured 14 instances by reading, " +
|
|
297
|
+
"each followed by the user handing the work back; this is the same " +
|
|
298
|
+
"predicate `turn-end-gate`'s completion detector refuses on, so the rate " +
|
|
299
|
+
"and the gate cannot disagree. A session that never polled CI can never hit.",
|
|
210
300
|
};
|
|
211
301
|
|
|
212
302
|
/**
|
|
@@ -317,7 +407,11 @@ export function isInjectedBody(text: string): boolean {
|
|
|
317
407
|
* `API Error: 529 Overloaded` retry banners. Counting the harness as the model
|
|
318
408
|
* inflates the very number this scan exists to report honestly.
|
|
319
409
|
*/
|
|
320
|
-
|
|
410
|
+
// Exported since round 7 § 6.3: `probe_session_canary` needs the SAME predicate,
|
|
411
|
+
// and its first version had its own copy that omitted `You've hit your` — which
|
|
412
|
+
// turned a spend-limit banner into a missed greeting and reported 24/28 for a
|
|
413
|
+
// corpus that is 25/28.
|
|
414
|
+
export const HARNESS_TEXT =
|
|
321
415
|
/^(API Error:|Request (timed out|was aborted)|\[Request interrupted|Error: |Credit balance is too low|You've hit your|Prompt is too long)/i;
|
|
322
416
|
|
|
323
417
|
/** Rough English-opener test for the language check (first prose line only). */
|
|
@@ -330,6 +424,8 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
330
424
|
user_turns: 0,
|
|
331
425
|
assistant_turns: 0,
|
|
332
426
|
violations: [],
|
|
427
|
+
de_pin_turns: 0,
|
|
428
|
+
first_at: "",
|
|
333
429
|
};
|
|
334
430
|
|
|
335
431
|
let pinned: "de" | "en" | null = null;
|
|
@@ -343,6 +439,10 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
343
439
|
// that split reproducible against a future corpus instead of re-argued.
|
|
344
440
|
let turnsSincePrompt = 0;
|
|
345
441
|
let compactionSincePrompt = false;
|
|
442
|
+
// Round 7 § 1.5 — session-scoped, deliberately NOT reset per prompt: the
|
|
443
|
+
// measured failure is a completion claim in a LATER turn than the poll.
|
|
444
|
+
let ciSeen = false;
|
|
445
|
+
let ciSettled = false;
|
|
346
446
|
|
|
347
447
|
for (const line of lines) {
|
|
348
448
|
if (!line.trim()) {
|
|
@@ -354,6 +454,12 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
354
454
|
} catch {
|
|
355
455
|
continue;
|
|
356
456
|
}
|
|
457
|
+
// Round 7 § 6.1 — captured for the era guard. The FIRST timestamp in the
|
|
458
|
+
// file, not the file mtime: mtime moves when a session is resumed, which
|
|
459
|
+
// would put a July session in the August era.
|
|
460
|
+
if (report.first_at === "" && typeof entry["timestamp"] === "string") {
|
|
461
|
+
report.first_at = entry["timestamp"];
|
|
462
|
+
}
|
|
357
463
|
|
|
358
464
|
// Tool results arrive as `user` entries carrying `toolUseResult`.
|
|
359
465
|
if (entry["type"] === "user" && entry["toolUseResult"] !== undefined && pendingPoll !== null) {
|
|
@@ -370,6 +476,14 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
370
476
|
detail: `CI poll read as settled without an in-flight observation: ${out.slice(0, 90)}`,
|
|
371
477
|
});
|
|
372
478
|
}
|
|
479
|
+
// Round 7 § 1.5 — the session-scoped CI-settle state the completion-claim
|
|
480
|
+
// check reads. R2 finding 3: this copied `pending === 0` AND the wrong
|
|
481
|
+
// comment claiming it matched the producer's discrimination. It did not —
|
|
482
|
+
// a stale all-pass table read as a settle and silenced the check. The
|
|
483
|
+
// in-flight witness is required here too, so the measurement and the gate
|
|
484
|
+
// agree on what a settle is.
|
|
485
|
+
ciSeen = true;
|
|
486
|
+
ciSettled = pending === 0 && sawPending && !isVacuousOutput(out);
|
|
373
487
|
pendingPoll = null;
|
|
374
488
|
continue;
|
|
375
489
|
}
|
|
@@ -424,6 +538,26 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
424
538
|
if (prose !== null) {
|
|
425
539
|
report.assistant_turns += 1;
|
|
426
540
|
turnsSincePrompt += 1;
|
|
541
|
+
// Round 7 § 6.2 — the denominator the language check can actually fire on.
|
|
542
|
+
// Counted here, beside the numerator, so the two cannot be derived from
|
|
543
|
+
// different populations later.
|
|
544
|
+
if (pinned === "de") {
|
|
545
|
+
report.de_pin_turns += 1;
|
|
546
|
+
}
|
|
547
|
+
// Round 7 § 1.5 — placed BEFORE the language block on purpose: that block
|
|
548
|
+
// `continue`s on a harness banner, and a check placed after it would be
|
|
549
|
+
// skipped for every such turn without saying so.
|
|
550
|
+
if (!HARNESS_TEXT.test((prose.split("\n").find((l) => l.trim()) ?? "").trim())) {
|
|
551
|
+
const cc = detectCompletionClaim(prose, { seen: ciSeen, settled: ciSettled });
|
|
552
|
+
if (cc !== null) {
|
|
553
|
+
report.violations.push({
|
|
554
|
+
check: "completion-claim",
|
|
555
|
+
session: sessionId,
|
|
556
|
+
at,
|
|
557
|
+
detail: `completion claimed over an unsettled CI read: "${cc.evidence.slice(0, 110)}"`,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
427
561
|
if (pinned === "de") {
|
|
428
562
|
const first = prose.split("\n").find((l) => l.trim()) ?? "";
|
|
429
563
|
if (HARNESS_TEXT.test(first.trim())) {
|
|
@@ -585,6 +719,7 @@ export function scanStore(store: string, limit: number): ScanReport {
|
|
|
585
719
|
"git-authorization": 0,
|
|
586
720
|
"vacuous-evidence": 0,
|
|
587
721
|
"evidence-steering": 0,
|
|
722
|
+
"completion-claim": 0,
|
|
588
723
|
};
|
|
589
724
|
for (const s of per_session) {
|
|
590
725
|
for (const v of s.violations) {
|
|
@@ -600,6 +735,14 @@ export function scanStore(store: string, limit: number): ScanReport {
|
|
|
600
735
|
// band" — a rounding artefact announcing the declared falsifier.
|
|
601
736
|
const rate_pct =
|
|
602
737
|
assistant_turns === 0 ? 0 : Number(((100 * language_pin) / assistant_turns).toFixed(1));
|
|
738
|
+
// Round 7 § 6.2 — the second denominator, reported beside the first rather than
|
|
739
|
+
// instead of it: replacing the figure silently would break comparability with
|
|
740
|
+
// every recorded run, and the band's own three reference values were computed
|
|
741
|
+
// on the all-turns denominator.
|
|
742
|
+
const de_pin_turns = per_session.reduce((n, s) => n + s.de_pin_turns, 0);
|
|
743
|
+
const rate_pct_de_pin =
|
|
744
|
+
de_pin_turns === 0 ? 0 : Number(((100 * language_pin) / de_pin_turns).toFixed(1));
|
|
745
|
+
const era = spansCarrierChange(per_session.map((s) => s.first_at));
|
|
603
746
|
const delivered = measureDelivered(
|
|
604
747
|
path.join(REPO_ROOT, ".claude", "rules"),
|
|
605
748
|
path.join(process.env["HOME"] ?? os.homedir(), ".claude", "rules"),
|
|
@@ -619,7 +762,11 @@ export function scanStore(store: string, limit: number): ScanReport {
|
|
|
619
762
|
assistant_turns,
|
|
620
763
|
language_pin,
|
|
621
764
|
rate_pct,
|
|
622
|
-
|
|
765
|
+
de_pin_turns,
|
|
766
|
+
rate_pct_de_pin,
|
|
767
|
+
era_spanning: era.spans,
|
|
768
|
+
era_reason: era.what,
|
|
769
|
+
band: bandVerdict(rate_pct, assistant_turns, { spansCarrier: era.spans }),
|
|
623
770
|
// Only attach the carrier figures when the rate and the carriers describe
|
|
624
771
|
// the same project.
|
|
625
772
|
delivered_project_tokens: ownStore ? delivered.project.tokens : null,
|
|
@@ -680,7 +827,36 @@ export function render(report: ScanReport): string {
|
|
|
680
827
|
` language-pin rate ${report.rate.rate_pct.toFixed(1)}% ` +
|
|
681
828
|
`(${report.rate.language_pin} of ${report.rate.assistant_turns} assistant turns)`,
|
|
682
829
|
);
|
|
683
|
-
|
|
830
|
+
// Round 7 § 6.2 — both denominators, always. The band's three reference values
|
|
831
|
+
// were computed on the all-turns one, so that stays the headline; the second is
|
|
832
|
+
// the only one the check can actually fire on, and on round 7's own corpus the
|
|
833
|
+
// two differ by enough to move the verdict (6.5 % vs 9.2 %, band low 9.1 %).
|
|
834
|
+
// Optional-read, not `report.rate.x.toFixed(...)`: a `rate` block RECORDED
|
|
835
|
+
// before round 7 carries neither field, and `--record` exists to be read back
|
|
836
|
+
// over time. A renderer that throws on last month's line makes the series
|
|
837
|
+
// unreadable to prove a point about denominators.
|
|
838
|
+
const dePinTurns = report.rate.de_pin_turns;
|
|
839
|
+
const dePinRate = report.rate.rate_pct_de_pin;
|
|
840
|
+
if (typeof dePinTurns === 'number' && typeof dePinRate === 'number') {
|
|
841
|
+
lines.push(
|
|
842
|
+
` ${dePinRate.toFixed(1)}% ` +
|
|
843
|
+
`(${report.rate.language_pin} of ${dePinTurns} turns under a GERMAN pin — the only` +
|
|
844
|
+
' turns this check can fire on)',
|
|
845
|
+
);
|
|
846
|
+
lines.push(
|
|
847
|
+
' The band (M5, n=3) was computed on the ALL-turns denominator, so the first figure',
|
|
848
|
+
);
|
|
849
|
+
lines.push(' is the comparable one and the second is the honest one. Both are printed.');
|
|
850
|
+
}
|
|
851
|
+
if (report.rate.band === "era-spanning") {
|
|
852
|
+
lines.push(
|
|
853
|
+
` Not compared: the corpus SPANS a carrier landing — ${report.rate.era_reason || 'see CARRIER_CHANGES'}.`,
|
|
854
|
+
);
|
|
855
|
+
lines.push(' A rate pooled across it is a weighted average of two different systems, not');
|
|
856
|
+
lines.push(' a reading about behaviour. Round 7 measured 23.1% before and 0.0% after that');
|
|
857
|
+
lines.push(' exact carrier; the pooled 6.5% was announced as the falsifier firing.');
|
|
858
|
+
lines.push(' Split the window at the carrier date and read each era on its own.');
|
|
859
|
+
} else if (report.rate.band === "corpus-too-small") {
|
|
684
860
|
lines.push(
|
|
685
861
|
` Not compared: ${report.rate.assistant_turns} turns is below the ${BAND_MIN_TURNS} of the`,
|
|
686
862
|
);
|
|
@@ -702,8 +878,23 @@ export function render(report: ScanReport): string {
|
|
|
702
878
|
lines.push(" as new. Store novelty is not project novelty.");
|
|
703
879
|
}
|
|
704
880
|
lines.push("");
|
|
705
|
-
|
|
706
|
-
|
|
881
|
+
// Round 7 § 6.4 — this footer used to lump four classes together as "NOT
|
|
882
|
+
// measured", and two of the four were wrong. Stated per class instead, because
|
|
883
|
+
// "not measured here" and "not measured anywhere" are different facts and only
|
|
884
|
+
// one of them is a gap.
|
|
885
|
+
lines.push(" Scanned checks are exactly the mechanised ones. What is NOT in the counts above:");
|
|
886
|
+
lines.push(" ask-shape (trailing free-text offer) — measured NOWHERE. ~11 findings in round 7's");
|
|
887
|
+
lines.push(" reading half; the discriminator needs judgement, so it stays advisory on purpose.");
|
|
888
|
+
lines.push(" session-canary (opening greeting) — not here, but PROBEABLE:");
|
|
889
|
+
lines.push(" `./scripts-run src/scripts/probe_session_canary`. Round 7: 25/28 sessions,");
|
|
890
|
+
lines.push(" 24/25 post-carrier. The per-TASK instance is undecidable — no task boundary");
|
|
891
|
+
lines.push(" is recorded in a transcript.");
|
|
892
|
+
lines.push(" promissory closings — not here, but ALREADY GATED: `turn-end-gate`");
|
|
893
|
+
lines.push(" ships a blocking `detectPromissory`. Round 7 probe: 1 of 120 hand-back turns");
|
|
894
|
+
lines.push(" (the 163 this line first printed was the retracted denominator — it counted");
|
|
895
|
+
lines.push(" synthetic user turns as hand-back closers).");
|
|
896
|
+
lines.push(" `./scripts-run src/scripts/probe_promissory_closing`.");
|
|
897
|
+
lines.push(" checkbox batching — measured NOWHERE. ~5 low-severity findings.");
|
|
707
898
|
return lines.join("\n");
|
|
708
899
|
}
|
|
709
900
|
|
|
@@ -749,11 +749,18 @@ function _postRunQuorum(
|
|
|
749
749
|
for (let i = 0; i < members.length; i++) {
|
|
750
750
|
const m = members[i] as ExternalAIClient;
|
|
751
751
|
const r = responses[i];
|
|
752
|
-
|
|
752
|
+
// Round 7 § 5.2 — attendance is a NON-EMPTY answer, not the absence of an
|
|
753
|
+
// error. `!r.error` alone counted a member that returned zero bytes, and
|
|
754
|
+
// that is not hypothetical: a 290 s curl timeout returned an empty body
|
|
755
|
+
// with no error set in two sessions (`9fc9ba3e`, `4ac2f7ac`) and the
|
|
756
|
+
// banner printed `2/2 present` — a single-voice verdict presented as
|
|
757
|
+
// convergence, on a paid run. An empty answer contributes nothing to a
|
|
758
|
+
// quorum by definition, whatever the transport thought of it.
|
|
759
|
+
if (r !== undefined && !r.error && r.text.trim() !== '') {
|
|
753
760
|
present += 1;
|
|
754
761
|
continue;
|
|
755
762
|
}
|
|
756
|
-
const raw = r?.error ?? 'no response';
|
|
763
|
+
const raw = r?.error ?? (r !== undefined && r.text.trim() === '' ? 'empty response body' : 'no response');
|
|
757
764
|
const failure = classifyCliFailure(raw);
|
|
758
765
|
absent.push({
|
|
759
766
|
member: m.name,
|
|
@@ -772,7 +779,14 @@ function _postRunQuorum(
|
|
|
772
779
|
*/
|
|
773
780
|
function _format_quorum_line(q: QuorumResult): string {
|
|
774
781
|
const verdict = q.status === 'concluded' ? 'concluded' : 'INCONCLUSIVE — release gate holds';
|
|
775
|
-
|
|
782
|
+
// Round 7 § 5.3 — a degraded pass says so. `1/2 present, needed 1 —
|
|
783
|
+
// concluded` is literally true and reads as agreement; with a threshold of 1
|
|
784
|
+
// a solo answer concludes, and nothing in the line distinguishes "both
|
|
785
|
+
// members agreed" from "one member answered". The counts were always there;
|
|
786
|
+
// what was missing is the word that stops a reader inferring convergence.
|
|
787
|
+
const degraded =
|
|
788
|
+
q.present < q.total ? ` ⚠️ DEGRADED — ${String(q.total - q.present)} member(s) did not answer; this is not convergence.` : '';
|
|
789
|
+
return `council:quorum · ${q.present}/${q.total} present, needed ${q.threshold} — ${verdict}.${degraded}`;
|
|
776
790
|
}
|
|
777
791
|
|
|
778
792
|
function build_members(settings: Dict, opts: BuildMembersOptions = {}): ExternalAIClient[] {
|
|
@@ -2328,6 +2342,31 @@ function _emit_shadow_slo_banner(): void {
|
|
|
2328
2342
|
* leaves nothing to infer. Exit is always 0 — this reports a state, it does not
|
|
2329
2343
|
* gate — and the verdict line is machine-greppable in both directions.
|
|
2330
2344
|
*/
|
|
2345
|
+
/**
|
|
2346
|
+
* Resolve the concrete transport a member would use on THIS machine right now.
|
|
2347
|
+
*
|
|
2348
|
+
* `cmd_status` exists to answer "is the council reachable, and will it bill me".
|
|
2349
|
+
* Since the transport-mode setting was removed, the second half is not a value
|
|
2350
|
+
* anyone can look up in the config — it is a per-machine resolution. Printing a
|
|
2351
|
+
* configured mode would be the same class of stale answer the removed setting
|
|
2352
|
+
* produced. `mode` is pinned to `auto` because the loader no longer emits
|
|
2353
|
+
* anything else.
|
|
2354
|
+
*/
|
|
2355
|
+
function _resolvedTransportFor(
|
|
2356
|
+
name: string,
|
|
2357
|
+
member: { readonly binary: string | null; readonly api_key_ref: string | null },
|
|
2358
|
+
report?: EnvironmentReport,
|
|
2359
|
+
): ReturnType<typeof resolveMemberTransport> {
|
|
2360
|
+
return resolveMemberTransport({
|
|
2361
|
+
provider: name,
|
|
2362
|
+
report: report ?? detectEnvironment(),
|
|
2363
|
+
invocationMode: null,
|
|
2364
|
+
memberSettings: null,
|
|
2365
|
+
globalMode: 'auto',
|
|
2366
|
+
binaryOverride: member.binary,
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2331
2370
|
export function cmd_status(args: Args, opts: { env?: Record<string, string | undefined> } = {}): number {
|
|
2332
2371
|
const env = opts.env ?? process.env;
|
|
2333
2372
|
const override = env[COUNCIL_CONFIG_ENV];
|
|
@@ -2363,6 +2402,21 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
|
|
|
2363
2402
|
members_total: cfg === null ? null : cfg.members.size,
|
|
2364
2403
|
members_enabled: cfg === null ? null : enabledMembers.length,
|
|
2365
2404
|
member_names: enabledMembers.map(([n]) => n),
|
|
2405
|
+
transports: Object.fromEntries(
|
|
2406
|
+
enabledMembers.map(([n, m]) => {
|
|
2407
|
+
const t = _resolvedTransportFor(n, m);
|
|
2408
|
+
return [
|
|
2409
|
+
n,
|
|
2410
|
+
{
|
|
2411
|
+
available: t.available,
|
|
2412
|
+
transport: t.available ? t.transport : null,
|
|
2413
|
+
billing: t.available ? t.billing : null,
|
|
2414
|
+
reason: t.reason,
|
|
2415
|
+
},
|
|
2416
|
+
];
|
|
2417
|
+
}),
|
|
2418
|
+
),
|
|
2419
|
+
ignored_transport_keys: cfg?.ignored_transport_keys ?? [],
|
|
2366
2420
|
parse_error,
|
|
2367
2421
|
project_tree_consulted: false,
|
|
2368
2422
|
})}\n`,
|
|
@@ -2383,6 +2437,25 @@ export function cmd_status(args: Args, opts: { env?: Record<string, string | und
|
|
|
2383
2437
|
if (enabledMembers.length > 0) {
|
|
2384
2438
|
_stdout(` ${enabledMembers.map(([n]) => n).join(', ')}\n`);
|
|
2385
2439
|
}
|
|
2440
|
+
// Transport is resolved per machine, so the only honest way to answer
|
|
2441
|
+
// "will this cost money" is to resolve it here rather than print a
|
|
2442
|
+
// configured value. The billing class comes from the detected auth
|
|
2443
|
+
// source, never from the transport name — a community CLI wrapper
|
|
2444
|
+
// shells out to the same paid API and stays metered.
|
|
2445
|
+
for (const [name, member] of enabledMembers) {
|
|
2446
|
+
const t = _resolvedTransportFor(name, member);
|
|
2447
|
+
const billing = t.available ? ` · ${t.billing}` : '';
|
|
2448
|
+
const detail = t.available ? t.transport : `unavailable — ${t.reason ?? 'no usable transport'}`;
|
|
2449
|
+
_stdout(` transport ${name}: ${detail}${billing}\n`);
|
|
2450
|
+
}
|
|
2451
|
+
if (cfg.ignored_transport_keys.length > 0) {
|
|
2452
|
+
_stdout('\n');
|
|
2453
|
+
_stdout(' Ignored transport keys — transport is resolved, not configured:\n');
|
|
2454
|
+
for (const key of cfg.ignored_transport_keys) {
|
|
2455
|
+
_stdout(` ${key}\n`);
|
|
2456
|
+
}
|
|
2457
|
+
_stdout(' Safe to delete from the config file; they change nothing.\n');
|
|
2458
|
+
}
|
|
2386
2459
|
}
|
|
2387
2460
|
_stdout('\n');
|
|
2388
2461
|
_stdout(' The project tree is NEVER consulted (ADR-104). No project file —\n');
|
|
@@ -2593,6 +2666,25 @@ function cmd_run(
|
|
|
2593
2666
|
return 0;
|
|
2594
2667
|
}
|
|
2595
2668
|
|
|
2669
|
+
// Round 7 § 5.1 — VALIDATE THE OUTPUT PATH BEFORE THE FIRST BILLABLE CALL.
|
|
2670
|
+
//
|
|
2671
|
+
// The only validation used to sit immediately before the WRITE, i.e. after
|
|
2672
|
+
// every member had been paid, so a wrong directory discarded a completed run.
|
|
2673
|
+
// Measured across round 7's corpus: ~$1.30 in three sessions (`291f827b`
|
|
2674
|
+
// ~$0.42, `9502795e` $0.44, plus a discarded run each in `d6154522` and
|
|
2675
|
+
// `3d50d0df`). The trap sat in the operator's notes for weeks and was still
|
|
2676
|
+
// paid twice, because the defect is the ORDERING, not the caller.
|
|
2677
|
+
//
|
|
2678
|
+
// Placed HERE, after the `--confirm` gate, and not at function entry: the
|
|
2679
|
+
// estimate-only path neither writes nor bills, and the first attempt at this
|
|
2680
|
+
// fix validated at entry and broke it — caught by
|
|
2681
|
+
// `council_cli.test.ts`'s no-confirm case. Nothing above this line spends.
|
|
2682
|
+
//
|
|
2683
|
+
// The validator is pure, so the pre-write call stays: it returns the string
|
|
2684
|
+
// the write uses, and removing it to avoid "duplicate work" would put the
|
|
2685
|
+
// guarantee back in a single place that runs late.
|
|
2686
|
+
_validate_council_output_path(args.output as string, { kind: 'responses', subcommand: 'run' });
|
|
2687
|
+
|
|
2596
2688
|
const cost_cfg = (ai_cfg['cost_budget'] as Dict) || {};
|
|
2597
2689
|
const budget = new CostBudget({
|
|
2598
2690
|
max_input_tokens: _pyInt(cost_cfg['max_input_tokens'] ?? 50_000, 50_000),
|
|
@@ -60,8 +60,12 @@ concerns:
|
|
|
60
60
|
severity: advisory
|
|
61
61
|
# Phase 5 — Tier-1 hook for the verify-before-complete rule. Records
|
|
62
62
|
# observable evidence of verification commands (tests, quality tools,
|
|
63
|
-
# builds) into agents/
|
|
64
|
-
#
|
|
63
|
+
# builds) into agents/state/verify-before-complete.json — the path the code
|
|
64
|
+
# actually writes (`before_complete_hook.STATE_FILE`); this comment said
|
|
65
|
+
# `agents/runtime/state/` until round 7, which is a directory it never used.
|
|
66
|
+
# The rule body cites that file as the source of truth for "verified this
|
|
67
|
+
# turn?". Since round 7 it also carries `ci_last`, the session-scoped CI-settle
|
|
68
|
+
# negative that `turn-end-gate` reads to refuse a premature completion claim.
|
|
65
69
|
verify-before-complete:
|
|
66
70
|
script: src/scripts/before_complete_hook.ts
|
|
67
71
|
args: []
|
|
@@ -447,10 +451,12 @@ concerns:
|
|
|
447
451
|
# mismatch (19 measured) — because building the unsafe part twice is how a
|
|
448
452
|
# second detector becomes a second outage.
|
|
449
453
|
#
|
|
450
|
-
#
|
|
451
|
-
#
|
|
452
|
-
#
|
|
453
|
-
#
|
|
454
|
+
# ALWAYS ARMED — no settings surface (2026-08-12). It shipped default-OFF on
|
|
455
|
+
# the round-6 council's "soak before it binds", and the maintainer removed the
|
|
456
|
+
# switch on the ground that a concern which is off cannot soak: the flag made
|
|
457
|
+
# the soak it protected unreachable. Whether the gate fires is decided by each
|
|
458
|
+
# detector's own trigger conditions, which is the same shape `delegation-nudge`
|
|
459
|
+
# uses — no signal, no output. `hooks.turn_end_gate.*` is in REMOVED_KEYS.
|
|
454
460
|
#
|
|
455
461
|
# fail_closed: FALSE, deliberately and unlike every other blocking concern
|
|
456
462
|
# here: a crash in a turn-end gate must let the turn END. Failing closed on
|
|
@@ -409,31 +409,104 @@ export function _looks_like_git_invocation(cmd: string, depth = 0): boolean {
|
|
|
409
409
|
* Heredoc bodies are removed before extraction: a backtick or `$(` inside a
|
|
410
410
|
* commit message being written to a file is data, and feeding it back through
|
|
411
411
|
* this function is exactly the false positive the round-5 repair closed.
|
|
412
|
+
*
|
|
413
|
+
* ROUND 7 — THREAT MODEL for extending that removal to the shlex path (§ 2.1).
|
|
414
|
+
* Until round 7 the stripped string fed only `substitutionPayloads`; `shlexSplit`
|
|
415
|
+
* still received the RAW command. Both halves of that were measured, on this
|
|
416
|
+
* branch, before anything changed:
|
|
417
|
+
*
|
|
418
|
+
* `git commit -F - <<'EOF' … maintainer's … EOF` → BLOCKED (false positive)
|
|
419
|
+
* `bash <<EOF … git commit --no-verify … EOF` → ALLOWED (real bypass)
|
|
420
|
+
*
|
|
421
|
+
* So this is not the round-6 trade of one false positive for two false negatives.
|
|
422
|
+
* The bypass PRE-EXISTS: with the body inline, `shlexSplit` succeeds, the tokens
|
|
423
|
+
* are `bash <<EOF git commit --no-verify EOF`, command position is `bash`, and
|
|
424
|
+
* `_git_base` never sees git. Stripping before shlex fixes the false positive;
|
|
425
|
+
* scanning the bodies fixes the bypass. Both directions verified by probe.
|
|
426
|
+
*
|
|
427
|
+
* What an attacker can hide in a heredoc body, and how each is handled:
|
|
428
|
+
*
|
|
429
|
+
* - a body consumed by a SHELL (`sh`/`bash`/`zsh`/`ksh`, with or without a path
|
|
430
|
+
* prefix or an `env` prefix) IS a command list → scanned recursively.
|
|
431
|
+
* - a body consumed by anything else (`git commit -F -`, `cat > f`, `tee`) is
|
|
432
|
+
* DATA. Scanning it would re-open the exact false positive above, since a
|
|
433
|
+
* commit message may legitimately contain the words `--no-verify`.
|
|
434
|
+
* - an UNTERMINATED heredoc never matches the delimiter, so nothing is stripped
|
|
435
|
+
* and the raw string still reaches shlex — the fail-closed branch keeps it.
|
|
436
|
+
*/
|
|
437
|
+
|
|
438
|
+
/** One heredoc: its body, and whether a shell is the thing consuming it. */
|
|
439
|
+
interface Heredoc {
|
|
440
|
+
body: string;
|
|
441
|
+
shellConsumed: boolean;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Command position on the line that opens the heredoc is a shell interpreter, so
|
|
446
|
+
* the body is a command list rather than data. Anchored to the segment start or a
|
|
447
|
+
* shell separator, exactly like `_looks_like_git_invocation` does one level up —
|
|
448
|
+
* a bare mention of `bash` in an argument must not promote a message to code.
|
|
412
449
|
*/
|
|
450
|
+
const _SHELL_CONSUMER_RE =
|
|
451
|
+
/(?:^|[|&;]|\$\(|`)\s*(?:env\s+(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*)?(?:\S*\/)?(?:ba|z|k)?sh\b/;
|
|
452
|
+
|
|
453
|
+
export function _heredocs(cmd: string): { stripped: string; docs: Heredoc[] } {
|
|
454
|
+
const re = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1([\s\S]*?)^\s*\2\s*$/gm;
|
|
455
|
+
const docs: Heredoc[] = [];
|
|
456
|
+
let stripped = '';
|
|
457
|
+
let last = 0;
|
|
458
|
+
let m: RegExpExecArray | null;
|
|
459
|
+
while ((m = re.exec(cmd)) !== null) {
|
|
460
|
+
const lineStart = cmd.lastIndexOf('\n', m.index) + 1;
|
|
461
|
+
const prefix = cmd.slice(lineStart, m.index);
|
|
462
|
+
docs.push({ body: m[3] ?? '', shellConsumed: _SHELL_CONSUMER_RE.test(prefix) });
|
|
463
|
+
stripped += cmd.slice(last, m.index) + '<<HEREDOC';
|
|
464
|
+
last = m.index + m[0].length;
|
|
465
|
+
}
|
|
466
|
+
stripped += cmd.slice(last);
|
|
467
|
+
return { stripped, docs };
|
|
468
|
+
}
|
|
469
|
+
|
|
413
470
|
function _check_command(cmd: string, depth = 0): [boolean, string] {
|
|
471
|
+
// Round 7 § 2.2 — the stripped string now feeds shlex as well, not only the
|
|
472
|
+
// substitution scan. `<<HEREDOC` is a plain token, so an apostrophe in a
|
|
473
|
+
// commit message can no longer abort the parse of the command carrying it.
|
|
474
|
+
const { stripped, docs } = depth <= 3 ? _heredocs(cmd) : { stripped: cmd, docs: [] };
|
|
414
475
|
if (depth <= 3) {
|
|
415
|
-
const
|
|
416
|
-
/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[\s\S]*?^\s*\2\s*$/gm,
|
|
417
|
-
'<<HEREDOC',
|
|
418
|
-
);
|
|
419
|
-
for (const payload of substitutionPayloads(withoutHeredocs)) {
|
|
476
|
+
for (const payload of substitutionPayloads(stripped)) {
|
|
420
477
|
const [blocked, reason] = _check_command(payload, depth + 1);
|
|
421
478
|
if (blocked) {
|
|
422
479
|
return [true, reason];
|
|
423
480
|
}
|
|
424
481
|
}
|
|
482
|
+
// A shell-consumed body is a command list, so it is checked like one.
|
|
483
|
+
for (const doc of docs) {
|
|
484
|
+
if (!doc.shellConsumed) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const [blocked, reason] = _check_command(doc.body, depth + 1);
|
|
488
|
+
if (blocked) {
|
|
489
|
+
return [true, reason];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
425
492
|
}
|
|
426
493
|
let tokens: string[];
|
|
427
494
|
try {
|
|
428
|
-
tokens = shlexSplit(
|
|
495
|
+
tokens = shlexSplit(stripped);
|
|
429
496
|
} catch (e) {
|
|
430
497
|
if (e instanceof ShlexError) {
|
|
431
|
-
|
|
498
|
+
// Tested against the string that actually failed to parse. Round 7
|
|
499
|
+
// § 2.4: the message no longer prescribes the message-in-a-file
|
|
500
|
+
// workaround, because a TERMINATED heredoc is now stripped before
|
|
501
|
+
// this point and can no longer be the cause. What reaches here is
|
|
502
|
+
// genuinely unbalanced quoting — an unterminated heredoc, or an odd
|
|
503
|
+
// quote outside one — which bash itself refuses to run.
|
|
504
|
+
if (_looks_like_git_invocation(stripped)) {
|
|
432
505
|
return [
|
|
433
506
|
true,
|
|
434
507
|
'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline). \n'
|
|
435
|
-
+ '
|
|
436
|
-
+ '
|
|
508
|
+
+ 'The quoting is unbalanced OUTSIDE any terminated heredoc — an unterminated heredoc, or an odd quote. \n'
|
|
509
|
+
+ 'bash refuses this command too; fix the quoting rather than working around the guard.',
|
|
437
510
|
];
|
|
438
511
|
}
|
|
439
512
|
return [false, ''];
|