@event4u/agent-config 14.2.0 → 14.4.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 +286 -218
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- 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 +3684 -1719
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +16 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The checkbox vocabulary and the phase-span rule, in one place.
|
|
3
|
+
*
|
|
4
|
+
* R2 review, findings 11 and 15. Two scanners on the autonomous-run path —
|
|
5
|
+
* `run_checkpoint.countRoadmap` and `run_continuation_hook.scanOpenSteps` —
|
|
6
|
+
* each carried their own narrower regex and each scanned the WHOLE file, while
|
|
7
|
+
* both docblocks claimed the dashboard's vocabulary. Two consequences, and
|
|
8
|
+
* they pull in opposite directions:
|
|
9
|
+
*
|
|
10
|
+
* - NARROWER VOCABULARY under-counts. Only `-` bullets and a lowercase `x`
|
|
11
|
+
* matched, so a roadmap authored with `*` bullets or `[X]` read
|
|
12
|
+
* `open === 0` and `run_supervise.classify` reported disposition
|
|
13
|
+
* `complete` — "the run finished, the session just never said so" — for a
|
|
14
|
+
* run with real open work. That is the exact confusion the watcher exists
|
|
15
|
+
* to remove.
|
|
16
|
+
* - WHOLE-FILE SCANNING over-counts. The dashboard attributes checkboxes to
|
|
17
|
+
* `## Phase` spans; these did not. On the very roadmap under review every
|
|
18
|
+
* phase step is closed and the only `- [ ]` sits under `## Acceptance criteria`, so
|
|
19
|
+
* the continuation would have named "A killed session resumes via the
|
|
20
|
+
* watcher…" as the next step — an observation of a live multi-day run, not
|
|
21
|
+
* an executable step, and by construction with no `verify:` line. Steps
|
|
22
|
+
* under `## Blockers` and `## Risk Register` are the same shape.
|
|
23
|
+
*
|
|
24
|
+
* The vocabulary is deliberately the dashboard's, character for character:
|
|
25
|
+
* where an autonomous run and the dashboard disagree about whether a roadmap
|
|
26
|
+
* is finished, the disagreement is the defect.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Bullet `-` or `*`, mark ` `, `x`, `X`, `~` or `-`; body captured. */
|
|
30
|
+
export const CHECKBOX_LINE = /^[ \t]*[-*][ \t]+\[([ xX~-])\][ \t]+(.*)$/;
|
|
31
|
+
|
|
32
|
+
/** H2/H3 heading, its text captured. */
|
|
33
|
+
const HEADING = /^(#{2,3})[ \t]+(.*?)[ \t]*$/;
|
|
34
|
+
|
|
35
|
+
/** An H2/H3 that opens a phase span, matching the dashboard's PHASE_RE shape. */
|
|
36
|
+
const PHASE_HEADING = /^Phase[ \t]+(?:\d+(?:\.\d+)*[a-z]?|[IVX]+|[A-Z](?:\d+)?)\b/;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The lines of `text` that sit inside a `## Phase …` span.
|
|
40
|
+
*
|
|
41
|
+
* A phase span runs from its heading to the next heading AT ITS OWN LEVEL OR
|
|
42
|
+
* SHALLOWER. A DEEPER heading does not close it — an `### Measurement A`
|
|
43
|
+
* inside a `## Phase 1` is part of that phase, not the end of it.
|
|
44
|
+
*
|
|
45
|
+
* R2 round 6, critical finding 2, and it was live in this tree rather than
|
|
46
|
+
* hypothetical. The first version closed the span at ANY H2/H3, so on
|
|
47
|
+
* `road-to-ui-track-integrity-followup.md` — whose `## Phase 1` is immediately
|
|
48
|
+
* followed by three `###` sub-headings — `countRoadmap` returned `open: 0`
|
|
49
|
+
* against ten real open checkboxes. `ladder` then answers `complete` and
|
|
50
|
+
* `run_supervise` reports "the run finished, the session just never said so".
|
|
51
|
+
* That is the exact false-completion this module was written to prevent,
|
|
52
|
+
* produced by the module itself.
|
|
53
|
+
*
|
|
54
|
+
* `## Blockers`, `## Acceptance criteria` and `## Risk Register` still close a
|
|
55
|
+
* `## Phase` because they are H2s — the level rule gives that for free, and it
|
|
56
|
+
* is why the rule is a level comparison rather than a list of section names
|
|
57
|
+
* nobody would keep current.
|
|
58
|
+
*
|
|
59
|
+
* A roadmap with NO phase heading at all yields every line. That is the
|
|
60
|
+
* deliberate choice and it is the safe direction: an unphased roadmap is a
|
|
61
|
+
* legitimate shape, and returning nothing for it would make every such run
|
|
62
|
+
* read as `complete`, which is the more dangerous of the two errors.
|
|
63
|
+
*/
|
|
64
|
+
export function phaseLines(text: string): string[] {
|
|
65
|
+
const lines = text.split('\n');
|
|
66
|
+
const out: string[] = [];
|
|
67
|
+
// The heading level of the phase currently open, or 0 when none is.
|
|
68
|
+
let phaseLevel = 0;
|
|
69
|
+
let sawPhase = false;
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
const h = HEADING.exec(line);
|
|
72
|
+
if (h !== null) {
|
|
73
|
+
const level = (h[1] as string).length;
|
|
74
|
+
const isPhase = PHASE_HEADING.test(h[2] ?? '');
|
|
75
|
+
if (isPhase) {
|
|
76
|
+
phaseLevel = level;
|
|
77
|
+
sawPhase = true;
|
|
78
|
+
} else if (phaseLevel !== 0 && level <= phaseLevel) {
|
|
79
|
+
// A sibling or shallower section ends the phase.
|
|
80
|
+
phaseLevel = 0;
|
|
81
|
+
}
|
|
82
|
+
// A DEEPER non-phase heading leaves the span open.
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (phaseLevel !== 0) out.push(line);
|
|
86
|
+
}
|
|
87
|
+
return sawPhase ? out : lines;
|
|
88
|
+
}
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run_checkpoint — the deterministic checkpoint a dying run leaves behind,
|
|
3
|
+
* and the re-verification a resumed run owes before trusting it.
|
|
4
|
+
*
|
|
5
|
+
* road-to-long-horizon-execution Phase 3.0 / 3.2 (sequencing UOTL Phase 6.1).
|
|
6
|
+
*
|
|
7
|
+
* ## Why "deterministic" is the load-bearing word
|
|
8
|
+
*
|
|
9
|
+
* A handoff summary is authored — a model writes prose about where the work
|
|
10
|
+
* stands, and prose can be wrong in ways nothing catches. A checkpoint is
|
|
11
|
+
* DERIVED: every field here is recomputed from files on disk, so writing one
|
|
12
|
+
* costs no judgement and reading one back can be checked against the tree
|
|
13
|
+
* that produced it. The two are complements, not alternatives: the summary
|
|
14
|
+
* says what the run was thinking, this says what the run had actually done.
|
|
15
|
+
*
|
|
16
|
+
* ## Why a resumed run re-verifies rather than trusts
|
|
17
|
+
*
|
|
18
|
+
* The reference design this borrows from resumes by BOOKKEEPING — the daemon
|
|
19
|
+
* believes its own record of where a session was. That is fine while nothing
|
|
20
|
+
* else touches the tree, and wrong the moment something does: a human
|
|
21
|
+
* committing, a sibling worktree, a partially-applied edit the dying session
|
|
22
|
+
* never finished. So `verifyCheckpoint` recomputes the same fields and reports
|
|
23
|
+
* a per-field agreement rather than a boolean — a resumed run should be able
|
|
24
|
+
* to say WHICH claim went stale, not merely that something did.
|
|
25
|
+
*
|
|
26
|
+
* A disagreement is explicitly NOT an error here. Work landing between the
|
|
27
|
+
* checkpoint and the resume is the normal case, and a verifier that treats
|
|
28
|
+
* progress as corruption would refuse every healthy resume.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import * as fs from 'node:fs';
|
|
32
|
+
import * as path from 'node:path';
|
|
33
|
+
|
|
34
|
+
import { CHECKBOX_LINE, phaseLines } from './roadmap_checkboxes.js';
|
|
35
|
+
|
|
36
|
+
export const CHECKPOINT_DIR_REL = path.join('agents', 'runtime', 'state', 'checkpoints');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A run's derived state at the moment its session ended.
|
|
40
|
+
*
|
|
41
|
+
* Every field is recomputed, never remembered — that is what makes
|
|
42
|
+
* `verifyCheckpoint` possible at all.
|
|
43
|
+
*/
|
|
44
|
+
export interface RunCheckpoint {
|
|
45
|
+
readonly schema_version: 1;
|
|
46
|
+
/** The session-derived run id this checkpoint belongs to. */
|
|
47
|
+
readonly run_id: string;
|
|
48
|
+
/** Roadmap slug from the session's `sessions:claim` file. */
|
|
49
|
+
readonly roadmap: string;
|
|
50
|
+
/** Steps still `[ ]` at checkpoint time. */
|
|
51
|
+
readonly open_steps: number;
|
|
52
|
+
/** Steps already `[x]` at checkpoint time. */
|
|
53
|
+
readonly done_steps: number;
|
|
54
|
+
/** `[~]` deferred plus `[-]` cancelled — neither is open work. */
|
|
55
|
+
readonly parked_steps: number;
|
|
56
|
+
/** The next `[ ]` step's text, trimmed, or `null` when none remains. */
|
|
57
|
+
readonly next_step: string | null;
|
|
58
|
+
/** Commit the tree was on. A resume onto a different commit is normal, and visible. */
|
|
59
|
+
readonly head: string | null;
|
|
60
|
+
readonly written_at: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Counts derived from a roadmap body — the same vocabulary the loop uses. */
|
|
64
|
+
export interface RoadmapCounts {
|
|
65
|
+
open: number;
|
|
66
|
+
done: number;
|
|
67
|
+
parked: number;
|
|
68
|
+
next: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Count checkboxes in a roadmap body.
|
|
73
|
+
*
|
|
74
|
+
* `[~]` (deferred) and `[-]` (cancelled) are PARKED, not open: a resumed run
|
|
75
|
+
* that treated them as work to do would re-engage into the exact items a human
|
|
76
|
+
* decided not to do, which is the anti-stall mechanism manufacturing a stall.
|
|
77
|
+
*/
|
|
78
|
+
export function countRoadmap(text: string): RoadmapCounts {
|
|
79
|
+
const counts: RoadmapCounts = { open: 0, done: 0, parked: 0, next: null };
|
|
80
|
+
// Phase spans only, and the dashboard's own vocabulary — see
|
|
81
|
+
// `_lib/roadmap_checkboxes.ts` for what each half was getting wrong.
|
|
82
|
+
for (const raw of phaseLines(text)) {
|
|
83
|
+
const m = CHECKBOX_LINE.exec(raw);
|
|
84
|
+
if (m === null) continue;
|
|
85
|
+
const mark = m[1];
|
|
86
|
+
if (mark === 'x' || mark === 'X') {
|
|
87
|
+
counts.done += 1;
|
|
88
|
+
} else if (mark === '~' || mark === '-') {
|
|
89
|
+
counts.parked += 1;
|
|
90
|
+
} else {
|
|
91
|
+
counts.open += 1;
|
|
92
|
+
if (counts.next === null) {
|
|
93
|
+
counts.next = (m[2] ?? '').trim() || null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return counts;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function checkpointFile(repoRoot: string, runId: string): string {
|
|
101
|
+
return path.join(repoRoot, CHECKPOINT_DIR_REL, `${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function roadmapPath(repoRoot: string, slug: string): string {
|
|
105
|
+
return path.join(repoRoot, 'agents', 'roadmaps', `${slug}.md`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BuildOptions {
|
|
109
|
+
/** Injected so a test does not depend on the ambient git state. */
|
|
110
|
+
readonly head?: string | null;
|
|
111
|
+
readonly now?: () => Date;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The current commit, read straight off `.git` — no subprocess.
|
|
116
|
+
*
|
|
117
|
+
* R2 round 2, finding 5. `head` was populated only from `opts.head`, and the
|
|
118
|
+
* ONE production caller (`session_eol_hook`) never passed it, so the field was
|
|
119
|
+
* always `null` while the checkpoint's own docblock, the loop contract and the
|
|
120
|
+
* roadmap all describe it as carrying the commit the tree was on. Only the
|
|
121
|
+
* test injected a value, which is why nothing noticed.
|
|
122
|
+
*
|
|
123
|
+
* No `git rev-parse`: this runs on the Stop path, where a spawn is a cost paid
|
|
124
|
+
* on every reply. Two files answer it — and a WORKTREE is the case a naive
|
|
125
|
+
* reader gets wrong, because there `.git` is a FILE holding `gitdir: <path>`
|
|
126
|
+
* rather than a directory. This package is developed in worktrees, so the naive
|
|
127
|
+
* version would have returned null exactly where it was being exercised.
|
|
128
|
+
*
|
|
129
|
+
* Returns `null` on anything unexpected. A checkpoint without a commit is
|
|
130
|
+
* still useful; a Stop path that throws is not.
|
|
131
|
+
*/
|
|
132
|
+
export function readHead(repoRoot: string): string | null {
|
|
133
|
+
try {
|
|
134
|
+
let gitDir = path.join(repoRoot, '.git');
|
|
135
|
+
if (fs.statSync(gitDir).isFile()) {
|
|
136
|
+
const m = /^gitdir:\s*(.+)$/m.exec(fs.readFileSync(gitDir, 'utf-8').trim());
|
|
137
|
+
if (m === null) return null;
|
|
138
|
+
gitDir = path.resolve(repoRoot, m[1] as string);
|
|
139
|
+
}
|
|
140
|
+
// HEAD is per-worktree; REFS are not. A linked worktree's gitdir holds
|
|
141
|
+
// its own HEAD and a `commondir` pointer, and `refs/` + `packed-refs`
|
|
142
|
+
// live at that commondir — never beside HEAD.
|
|
143
|
+
//
|
|
144
|
+
// R2 round 3, finding 1, and the round-2 version of this function got
|
|
145
|
+
// it exactly half right: it handled `.git`-as-a-file and then resolved
|
|
146
|
+
// refs against the worktree gitdir, so `head` was null in every linked
|
|
147
|
+
// worktree — including the one this package is developed in. Its test
|
|
148
|
+
// passed because the fixture wrote refs beside HEAD, a layout git never
|
|
149
|
+
// produces. A fixture that agrees with the code instead of with reality
|
|
150
|
+
// reads as coverage and is worse than no test at all.
|
|
151
|
+
let refRoot = gitDir;
|
|
152
|
+
try {
|
|
153
|
+
const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim();
|
|
154
|
+
if (common !== '') refRoot = path.resolve(gitDir, common);
|
|
155
|
+
} catch {
|
|
156
|
+
// No commondir — a plain repository, where gitDir already is it.
|
|
157
|
+
}
|
|
158
|
+
const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf-8').trim();
|
|
159
|
+
// A detached HEAD is the sha itself.
|
|
160
|
+
if (/^[0-9a-f]{40}$/.test(head)) return head;
|
|
161
|
+
const ref = /^ref:\s*(.+)$/.exec(head);
|
|
162
|
+
if (ref === null) return null;
|
|
163
|
+
const refName = ref[1] as string;
|
|
164
|
+
try {
|
|
165
|
+
return fs.readFileSync(path.join(refRoot, refName), 'utf-8').trim() || null;
|
|
166
|
+
} catch {
|
|
167
|
+
// Packed refs: a loose file does not exist for every branch.
|
|
168
|
+
const packed = fs.readFileSync(path.join(refRoot, 'packed-refs'), 'utf-8');
|
|
169
|
+
for (const line of packed.split('\n')) {
|
|
170
|
+
const pm = /^([0-9a-f]{40})\s+(.+)$/.exec(line.trim());
|
|
171
|
+
if (pm !== null && pm[2] === refName) return pm[1] as string;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Derive a checkpoint from the tree. Returns `null` when the roadmap is
|
|
182
|
+
* unreadable — a checkpoint that guesses is worse than none, because the whole
|
|
183
|
+
* contract of this file is that its fields were computed rather than recalled.
|
|
184
|
+
*/
|
|
185
|
+
export function buildCheckpoint(
|
|
186
|
+
repoRoot: string,
|
|
187
|
+
runId: string,
|
|
188
|
+
slug: string,
|
|
189
|
+
opts: BuildOptions = {},
|
|
190
|
+
): RunCheckpoint | null {
|
|
191
|
+
let text: string;
|
|
192
|
+
try {
|
|
193
|
+
text = fs.readFileSync(roadmapPath(repoRoot, slug), 'utf-8');
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const counts = countRoadmap(text);
|
|
198
|
+
return {
|
|
199
|
+
schema_version: 1,
|
|
200
|
+
run_id: runId,
|
|
201
|
+
roadmap: slug,
|
|
202
|
+
open_steps: counts.open,
|
|
203
|
+
done_steps: counts.done,
|
|
204
|
+
parked_steps: counts.parked,
|
|
205
|
+
next_step: counts.next,
|
|
206
|
+
head: opts.head ?? readHead(repoRoot),
|
|
207
|
+
written_at: (opts.now ?? ((): Date => new Date()))().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Write a checkpoint. Best-effort: a failed write must never break a Stop. */
|
|
212
|
+
export function writeCheckpoint(repoRoot: string, cp: RunCheckpoint): string | null {
|
|
213
|
+
const file = checkpointFile(repoRoot, cp.run_id);
|
|
214
|
+
try {
|
|
215
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
216
|
+
fs.writeFileSync(file, `${JSON.stringify(cp, null, 2)}\n`, 'utf-8');
|
|
217
|
+
return file;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function readCheckpoint(repoRoot: string, runId: string): RunCheckpoint | null {
|
|
224
|
+
try {
|
|
225
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(checkpointFile(repoRoot, runId), 'utf-8'));
|
|
226
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
227
|
+
const o = parsed as Record<string, unknown>;
|
|
228
|
+
if (typeof o['run_id'] !== 'string' || typeof o['roadmap'] !== 'string') return null;
|
|
229
|
+
if (typeof o['open_steps'] !== 'number' || typeof o['done_steps'] !== 'number') return null;
|
|
230
|
+
return parsed as RunCheckpoint;
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The most recent checkpoint for a ROADMAP, across every run id.
|
|
238
|
+
*
|
|
239
|
+
* R2 review, finding 7. `readCheckpoint` keys on the run id, which is derived
|
|
240
|
+
* from the session id of the session that DIED — so a relaunched session, which
|
|
241
|
+
* by definition has a new id and no index from slug to old id, could not reach
|
|
242
|
+
* its own checkpoint through any path in the tree. `verifyCheckpoint` and
|
|
243
|
+
* `renderVerification` had zero production call sites, and the loop contract
|
|
244
|
+
* nonetheless instructed a resumed run that "the first act is verifyCheckpoint".
|
|
245
|
+
* The mitigation Risk 5 names was unreachable as shipped.
|
|
246
|
+
*
|
|
247
|
+
* The roadmap slug is the one key a relaunched run genuinely holds: it claims
|
|
248
|
+
* the same roadmap, which is what makes it a resume rather than a new run.
|
|
249
|
+
*
|
|
250
|
+
* Ties are broken by `written_at` descending, then by filename, so the result
|
|
251
|
+
* is deterministic when two checkpoints share a timestamp. A malformed or
|
|
252
|
+
* unreadable file is skipped rather than failing the lookup — a resume that
|
|
253
|
+
* refuses to start because one stale JSON file is corrupt is worse than a
|
|
254
|
+
* resume that verifies against the newest readable one.
|
|
255
|
+
*/
|
|
256
|
+
export function latestCheckpointFor(repoRoot: string, roadmap: string): RunCheckpoint | null {
|
|
257
|
+
const dir = path.join(repoRoot, CHECKPOINT_DIR_REL);
|
|
258
|
+
let names: string[];
|
|
259
|
+
try {
|
|
260
|
+
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
261
|
+
} catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
const found: RunCheckpoint[] = [];
|
|
265
|
+
for (const name of names.sort()) {
|
|
266
|
+
let parsed: unknown;
|
|
267
|
+
try {
|
|
268
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
269
|
+
} catch {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) continue;
|
|
273
|
+
const o = parsed as Record<string, unknown>;
|
|
274
|
+
if (o['roadmap'] !== roadmap) continue;
|
|
275
|
+
if (typeof o['run_id'] !== 'string') continue;
|
|
276
|
+
if (typeof o['open_steps'] !== 'number' || typeof o['done_steps'] !== 'number') continue;
|
|
277
|
+
found.push(parsed as RunCheckpoint);
|
|
278
|
+
}
|
|
279
|
+
if (found.length === 0) return null;
|
|
280
|
+
found.sort((a, b) => {
|
|
281
|
+
const at = String(a.written_at ?? '');
|
|
282
|
+
const bt = String(b.written_at ?? '');
|
|
283
|
+
if (at !== bt) return at < bt ? 1 : -1;
|
|
284
|
+
return a.run_id < b.run_id ? 1 : -1;
|
|
285
|
+
});
|
|
286
|
+
return found[0] ?? null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** One claim's verdict — the field, what was claimed, what is true now. */
|
|
290
|
+
export interface FieldVerdict {
|
|
291
|
+
readonly field: string;
|
|
292
|
+
readonly claimed: string | number | null;
|
|
293
|
+
readonly actual: string | number | null;
|
|
294
|
+
readonly agrees: boolean;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface VerifyResult {
|
|
298
|
+
/** `false` when the roadmap named by the checkpoint no longer reads. */
|
|
299
|
+
readonly readable: boolean;
|
|
300
|
+
readonly fields: readonly FieldVerdict[];
|
|
301
|
+
/** Every field agreed. NOT a precondition for resuming — see the header. */
|
|
302
|
+
readonly agrees: boolean;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Recompute the checkpoint's claims against the tree as it is NOW.
|
|
307
|
+
*
|
|
308
|
+
* Reports per-field rather than a boolean on purpose: "the checkpoint is
|
|
309
|
+
* stale" tells a resumed run nothing it can act on, while "open_steps claimed
|
|
310
|
+
* 4, actual 3" tells it a step landed after the checkpoint was written and
|
|
311
|
+
* which count to trust.
|
|
312
|
+
*/
|
|
313
|
+
export function verifyCheckpoint(repoRoot: string, cp: RunCheckpoint): VerifyResult {
|
|
314
|
+
let text: string;
|
|
315
|
+
try {
|
|
316
|
+
text = fs.readFileSync(roadmapPath(repoRoot, cp.roadmap), 'utf-8');
|
|
317
|
+
} catch {
|
|
318
|
+
return { readable: false, fields: [], agrees: false };
|
|
319
|
+
}
|
|
320
|
+
const counts = countRoadmap(text);
|
|
321
|
+
// Read ONCE, so the recorded `actual` and the verdict about it are one
|
|
322
|
+
// expression rather than two that happen to agree.
|
|
323
|
+
const head = readHead(repoRoot);
|
|
324
|
+
const fields: FieldVerdict[] = [
|
|
325
|
+
{ field: 'open_steps', claimed: cp.open_steps, actual: counts.open, agrees: cp.open_steps === counts.open },
|
|
326
|
+
{ field: 'done_steps', claimed: cp.done_steps, actual: counts.done, agrees: cp.done_steps === counts.done },
|
|
327
|
+
{ field: 'parked_steps', claimed: cp.parked_steps, actual: counts.parked, agrees: cp.parked_steps === counts.parked },
|
|
328
|
+
{ field: 'next_step', claimed: cp.next_step, actual: counts.next, agrees: cp.next_step === counts.next },
|
|
329
|
+
// `head` is recomputed like every other field. R2 round 3, finding 2:
|
|
330
|
+
// round 2 fixed only the WRITE half of finding 5, so the loop
|
|
331
|
+
// contract's "every field is recomputed" was false for exactly the
|
|
332
|
+
// field that says whether the tree moved under the run — which is the
|
|
333
|
+
// one a resume most needs to see. A disagreement here is the NORMAL
|
|
334
|
+
// case (a human committed, a sibling worktree moved) and is reported,
|
|
335
|
+
// never treated as corruption.
|
|
336
|
+
{
|
|
337
|
+
field: 'head',
|
|
338
|
+
claimed: cp.head,
|
|
339
|
+
actual: head,
|
|
340
|
+
// A `null` on EITHER side is "not known", never "different".
|
|
341
|
+
//
|
|
342
|
+
// R2 round 4, finding 2. `readHead` is explicitly designed to
|
|
343
|
+
// return null on any failure — no git, an unreadable ref, a
|
|
344
|
+
// packed-refs miss — so a strict `===` turned "I could not read
|
|
345
|
+
// the commit" into "the tree moved since the checkpoint", on a
|
|
346
|
+
// resume at the SAME commit with every other field agreeing. That
|
|
347
|
+
// is a false alarm on the one field whose whole job is to tell a
|
|
348
|
+
// human whether anything moved, and a false alarm there costs more
|
|
349
|
+
// than a missing one: it trains the reader to skip the line.
|
|
350
|
+
agrees: cp.head === null || head === null || cp.head === head,
|
|
351
|
+
},
|
|
352
|
+
];
|
|
353
|
+
return { readable: true, fields, agrees: fields.every((f) => f.agrees) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** A one-line-per-field report a resumed run can put in front of a human. */
|
|
357
|
+
export function renderVerification(cp: RunCheckpoint, res: VerifyResult): string {
|
|
358
|
+
if (!res.readable) {
|
|
359
|
+
return (
|
|
360
|
+
`checkpoint ${cp.run_id}: roadmap '${cp.roadmap}' no longer reads — ` +
|
|
361
|
+
`the claimed state cannot be re-verified, so none of it may be assumed.`
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
const lines = [`checkpoint ${cp.run_id} · roadmap ${cp.roadmap} · written ${cp.written_at}`];
|
|
365
|
+
// Three states, not two. R2 round 5, finding 3: the null-head tolerance
|
|
366
|
+
// added in round 4 is correct as a VERDICT — an unknown commit is not a
|
|
367
|
+
// moved one — but rendering it as `ok head: null` alongside "the tree
|
|
368
|
+
// still matches the checkpoint" tells the reader something was checked
|
|
369
|
+
// when nothing could be. UNKNOWN is a third word because the reader's next
|
|
370
|
+
// action differs: `ok` needs nothing, `CHANGED` names what moved, and
|
|
371
|
+
// UNKNOWN says this axis carries no information and the other four are
|
|
372
|
+
// what the verdict rests on.
|
|
373
|
+
// ONLY `head` can be unknown. R2 round 6, finding 6: the predicate was
|
|
374
|
+
// written for `head` — where `null` means "could not read" — and applied
|
|
375
|
+
// to all five fields, but for `next_step` a `null` is a documented VALUE
|
|
376
|
+
// ("or `null` when none remains"). A finished roadmap therefore rendered
|
|
377
|
+
// `UNKNOWN next_step: not readable` and claimed a field had not been
|
|
378
|
+
// compared when it had been, on the exact state a resume most wants to
|
|
379
|
+
// see. The field name is the discriminator because the meaning of `null`
|
|
380
|
+
// is a property of the field, not of the value.
|
|
381
|
+
const UNKNOWNABLE: ReadonlySet<string> = new Set(['head']);
|
|
382
|
+
let unknown = 0;
|
|
383
|
+
for (const f of res.fields) {
|
|
384
|
+
if (UNKNOWNABLE.has(f.field) && (f.claimed === null || f.actual === null)) {
|
|
385
|
+
unknown += 1;
|
|
386
|
+
lines.push(
|
|
387
|
+
` UNKNOWN ${f.field}: not readable ` +
|
|
388
|
+
`(claimed ${String(f.claimed)}, actual ${String(f.actual)}) — not compared`,
|
|
389
|
+
);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
lines.push(
|
|
393
|
+
f.agrees
|
|
394
|
+
? ` ok ${f.field}: ${String(f.actual)}`
|
|
395
|
+
: ` CHANGED ${f.field}: claimed ${String(f.claimed)} → actual ${String(f.actual)}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
if (!res.agrees) {
|
|
399
|
+
lines.push(' the tree moved since the checkpoint — the ACTUAL column is what to resume from.');
|
|
400
|
+
} else if (unknown > 0) {
|
|
401
|
+
lines.push(
|
|
402
|
+
` the ${res.fields.length - unknown} comparable field(s) match; ` +
|
|
403
|
+
`${unknown} could not be read and were not compared.`,
|
|
404
|
+
);
|
|
405
|
+
} else {
|
|
406
|
+
lines.push(' the tree still matches the checkpoint.');
|
|
407
|
+
}
|
|
408
|
+
return lines.join('\n');
|
|
409
|
+
}
|
|
@@ -321,10 +321,37 @@ export function delete_record(dir: string, session_id: string): boolean {
|
|
|
321
321
|
export interface ReadLiveOptions {
|
|
322
322
|
/** Unlink expired records as they are encountered. Default `false`. */
|
|
323
323
|
prune?: boolean;
|
|
324
|
+
/**
|
|
325
|
+
* How long an EXPIRED record survives the prune. Default 24 h.
|
|
326
|
+
*
|
|
327
|
+
* R2 round 4, finding 5. The register has two consumers with opposite
|
|
328
|
+
* needs and only one of them knew: `sessions:list` and the session-start
|
|
329
|
+
* hook both read with `prune: true`, while `run:supervise` exists
|
|
330
|
+
* ENTIRELY to classify expired records — the runs whose session died. So
|
|
331
|
+
* the watcher's whole input was deleted by two routine read paths, the
|
|
332
|
+
* hook one of them, which fires on every session start. The digest could
|
|
333
|
+
* then report nothing, and round 3's `clearCompleted` release could never
|
|
334
|
+
* fire because no completed candidate ever reached it.
|
|
335
|
+
*
|
|
336
|
+
* The 24 h is derived, not picked: `digest` is documented as the MORNING
|
|
337
|
+
* report, so a once-a-day reader must be able to see a full day of deaths.
|
|
338
|
+
* A shorter grace loses runs that died overnight, which is precisely the
|
|
339
|
+
* population the watcher was built for.
|
|
340
|
+
*
|
|
341
|
+
* `0` restores the old delete-on-sight behaviour for a caller that really
|
|
342
|
+
* wants it. Nothing in the tree passes it today.
|
|
343
|
+
*/
|
|
344
|
+
prune_grace_ms?: number;
|
|
324
345
|
/** Clock injection for tests. */
|
|
325
346
|
now?: Date;
|
|
326
347
|
}
|
|
327
348
|
|
|
349
|
+
/**
|
|
350
|
+
* How long an expired record outlives its expiry before a prune may remove it.
|
|
351
|
+
* See {@link ReadLiveOptions.prune_grace_ms} for why this is a day.
|
|
352
|
+
*/
|
|
353
|
+
export const PRUNE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
354
|
+
|
|
328
355
|
/**
|
|
329
356
|
* Every live record in the register, optionally pruning expired ones as it goes.
|
|
330
357
|
*
|
|
@@ -339,6 +366,7 @@ export interface ReadLiveOptions {
|
|
|
339
366
|
export function read_live_records(dir: string, options: ReadLiveOptions = {}): SessionRecord[] {
|
|
340
367
|
const now = options.now ?? new Date();
|
|
341
368
|
const prune = options.prune === true;
|
|
369
|
+
const grace = options.prune_grace_ms ?? PRUNE_GRACE_MS;
|
|
342
370
|
let names: string[];
|
|
343
371
|
try {
|
|
344
372
|
names = fs.readdirSync(dir).filter((n) => n.endsWith('.json'));
|
|
@@ -356,7 +384,10 @@ export function read_live_records(dir: string, options: ReadLiveOptions = {}): S
|
|
|
356
384
|
rec = null;
|
|
357
385
|
}
|
|
358
386
|
if (rec === null || is_expired(rec, now)) {
|
|
359
|
-
|
|
387
|
+
// A record is dropped from the LIVE set the moment it expires, and
|
|
388
|
+
// deleted from disk only after the grace window — the watcher
|
|
389
|
+
// reads the register directly and needs the expired ones.
|
|
390
|
+
if (prune && (rec === null || is_expired(rec, new Date(now.getTime() - grace)))) {
|
|
360
391
|
try {
|
|
361
392
|
fs.unlinkSync(full);
|
|
362
393
|
} catch {
|
|
@@ -41,6 +41,31 @@ function _sleepSync(ms: number): void {
|
|
|
41
41
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Options for {@link readStdinText}. */
|
|
45
|
+
export interface ReadStdinOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Give up and return `''` when the FIRST byte has not arrived within this
|
|
48
|
+
* many milliseconds. Undefined (the default) keeps the historical behaviour:
|
|
49
|
+
* the full {@link MAX_RETRIES} budget applies from the first read, which is
|
|
50
|
+
* what a `gh pr diff` pipe needs — its first byte can legitimately take a
|
|
51
|
+
* while, and reporting that as empty input is the defect this module exists
|
|
52
|
+
* to prevent.
|
|
53
|
+
*
|
|
54
|
+
* A HOOK is the opposite case, and it cost a CI hang to learn the difference.
|
|
55
|
+
* The host writes the payload synchronously on spawn, so a byte that has not
|
|
56
|
+
* arrived promptly is not a slow writer — it is an fd 0 nobody will ever
|
|
57
|
+
* write to, which is what a child inherits when a test spawns it without
|
|
58
|
+
* piping stdin. With no cap the dispatcher then span the full ~10 s budget on
|
|
59
|
+
* EVERY such invocation and returned "" anyway; measured 12.4 s per call
|
|
60
|
+
* against an immediate return before the retry loop existed. Hundreds of
|
|
61
|
+
* those hung three Node-Tests shards for over an hour.
|
|
62
|
+
*
|
|
63
|
+
* Once ANY byte has arrived the cap no longer applies: a writer demonstrably
|
|
64
|
+
* exists, so the full budget is right and the large-payload fix is intact.
|
|
65
|
+
*/
|
|
66
|
+
firstByteTimeoutMs?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
/**
|
|
45
70
|
* Read all of fd 0 as UTF-8.
|
|
46
71
|
*
|
|
@@ -48,10 +73,13 @@ function _sleepSync(ms: number): void {
|
|
|
48
73
|
* {@link MAX_RETRIES} consecutive EAGAINs — a caller must not be able to
|
|
49
74
|
* mistake a failed read for empty input.
|
|
50
75
|
*/
|
|
51
|
-
export function readStdinText(fd = 0): string {
|
|
76
|
+
export function readStdinText(fd = 0, opts: ReadStdinOptions = {}): string {
|
|
52
77
|
const chunks: Buffer[] = [];
|
|
53
78
|
const buf = Buffer.alloc(CHUNK);
|
|
54
79
|
let retries = 0;
|
|
80
|
+
let got_any = false;
|
|
81
|
+
const first_byte_deadline =
|
|
82
|
+
opts.firstByteTimeoutMs === undefined ? null : Date.now() + opts.firstByteTimeoutMs;
|
|
55
83
|
for (;;) {
|
|
56
84
|
let read: number;
|
|
57
85
|
try {
|
|
@@ -60,6 +88,11 @@ export function readStdinText(fd = 0): string {
|
|
|
60
88
|
} catch (exc) {
|
|
61
89
|
const code = (exc as NodeJS.ErrnoException).code;
|
|
62
90
|
if (code === 'EAGAIN') {
|
|
91
|
+
// An idle fd 0 is "no input", not a slow writer — but only
|
|
92
|
+
// before the first byte. See ReadStdinOptions.firstByteTimeoutMs.
|
|
93
|
+
if (!got_any && first_byte_deadline !== null && Date.now() >= first_byte_deadline) {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
63
96
|
retries += 1;
|
|
64
97
|
if (retries > MAX_RETRIES) {
|
|
65
98
|
throw new Error(
|
|
@@ -76,6 +109,7 @@ export function readStdinText(fd = 0): string {
|
|
|
76
109
|
throw exc;
|
|
77
110
|
}
|
|
78
111
|
if (read === 0) break;
|
|
112
|
+
got_any = true;
|
|
79
113
|
chunks.push(Buffer.from(buf.subarray(0, read)));
|
|
80
114
|
}
|
|
81
115
|
return Buffer.concat(chunks).toString('utf-8');
|
|
@@ -45,10 +45,18 @@ import { read_lockfile } from './installed_lock.js';
|
|
|
45
45
|
*
|
|
46
46
|
* The roadmap's § 0 names three (A promissory, B language, C verification).
|
|
47
47
|
* That was true of the draft and is not true of the tree: detector D
|
|
48
|
-
* (`completion`) landed under round 7 § Phase 1 and
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* (`completion`) landed under round 7 § Phase 1 and runs in the same detector
|
|
49
|
+
* list as the other three. Counting three would silently drop a detector's
|
|
50
|
+
* refusals, so the set is read off `DetectorId` in the gate rather than off the
|
|
51
|
+
* prose.
|
|
52
|
+
*
|
|
53
|
+
* This said "the same UNCONDITIONAL list" until 2026-08-18, which was wrong and
|
|
54
|
+
* is worth naming rather than quietly rewording: `main()` runs A and D only when
|
|
55
|
+
* no subagent dispatch is open. It does not change this set — every detector
|
|
56
|
+
* that CAN fire needs a counter — but it does change what a reader may conclude
|
|
57
|
+
* from a zero here: A's and D's counts are censored by that third allow path,
|
|
58
|
+
* and `docs/contracts/turn-end-detector-demotion.md` is where that consequence
|
|
59
|
+
* is carried.
|
|
52
60
|
*/
|
|
53
61
|
export const DETECTOR_IDS = [
|
|
54
62
|
'promissory',
|