@bongos/core 1.19.818 → 1.19.820
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/.bongos-core.json +85 -35
- package/.claude/skills/strand-fix/SKILL.md +11 -2
- package/.gitattributes +9 -0
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +23 -18
- package/docs/copy-registry.json +76 -31
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/gds-db-backup.md +46 -0
- package/migrations/core_246_session_type.sql +49 -0
- package/modules/discord/bug-attachments.js +36 -1
- package/modules/hall-ui/public/sessions.css +19 -0
- package/modules/hall-ui/public/sessions.html +16 -1
- package/modules/hall-ui/public/sessions.js +135 -6
- package/modules/sessions/db.js +92 -6
- package/modules/sessions/routes/sessions.js +18 -1
- package/modules/sessions/session-type.js +132 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/backfill-session-types.js +87 -0
- package/scripts/gds/bug-attachment-audit.js +143 -0
- package/scripts/gds/capture.js +19 -0
- package/scripts/gds/db-backup-nightly.sh +37 -1
- package/scripts/gds/fitness.js +12 -1
- package/scripts/gds/home-isolation-guard.js +126 -0
- package/scripts/gds/ship-merge.js +5 -1
- package/scripts/gds/ship-regen.js +29 -0
- package/scripts/gds/skill-preflight.js +28 -4
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +122 -0
- package/tests/cli_package.mjs +18 -2
- package/tests/cli_surface.mjs +31 -2
- package/tests/hall_roster_world.mjs +10 -5
- package/tests/home_isolation_guard.mjs +95 -0
- package/tests/module_api_lazy.mjs +32 -1
- package/tests/routine_capture_kind.mjs +147 -0
- package/tests/session_type.mjs +215 -0
- package/tests/ship_regen_copy_inventory.mjs +69 -0
- package/tests/skill_preflight.mjs +45 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/backfill-session-types.js — classify the sessions that predate the
|
|
5
|
+
// classifier (task 1003975, from idea 1001033).
|
|
6
|
+
//
|
|
7
|
+
// WHAT THIS IS, and what it is NOT. It writes ONE column, `session_records.session_type`,
|
|
8
|
+
// and only where that column is NULL. It moves no money, books no reward, touches
|
|
9
|
+
// no `task_ids` and creates no attribution. That separation is deliberate and it
|
|
10
|
+
// is the lesson this module already paid for once: the attribution sweep had to be
|
|
11
|
+
// windowed because it ran on the same tick as a payout and would have minted a mass
|
|
12
|
+
// retroactive payment nobody authorised (see the warning in modules/sessions/CLAUDE.md).
|
|
13
|
+
// A sweep that both labels and pays is the wrong shape whatever its intent, so this
|
|
14
|
+
// one cannot pay even in principle — db.setSessionType writes that column alone.
|
|
15
|
+
//
|
|
16
|
+
// WHY IT IS NEEDED. The upload path classifies every session from now on, but the
|
|
17
|
+
// corpus is ~4,600 rows old and none of them carry a verdict. Without this pass the
|
|
18
|
+
// ledger's new filter would only ever see today's sessions, which is exactly the
|
|
19
|
+
// half of the corpus nobody needs help reading.
|
|
20
|
+
//
|
|
21
|
+
// SAFETY. DRY-RUN BY DEFAULT: with no flags it classifies in memory, prints the
|
|
22
|
+
// distribution it WOULD write, and writes nothing. `--apply` performs the UPDATEs.
|
|
23
|
+
// Idempotent by construction: the verdict is a pure function of the row's own
|
|
24
|
+
// stored digest, so a re-run lands on the same answer, and a row that already has
|
|
25
|
+
// a type is never revisited (the query selects `WHERE session_type IS NULL`).
|
|
26
|
+
// `--limit N` bounds one pass so the first apply can be small and checked.
|
|
27
|
+
//
|
|
28
|
+
// Run: node scripts/gds/backfill-session-types.js [--limit N]
|
|
29
|
+
// node scripts/gds/backfill-session-types.js --apply
|
|
30
|
+
|
|
31
|
+
const { classifySession, SESSION_TYPES } = require('../../modules/sessions/session-type');
|
|
32
|
+
const db = require('../../modules/sessions/db');
|
|
33
|
+
const { arg, hasFlag } = require('./cli-lib');
|
|
34
|
+
|
|
35
|
+
const BATCH = 500;
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const apply = hasFlag('--apply');
|
|
39
|
+
const limitArg = Number(arg('--limit'));
|
|
40
|
+
const cap = Number.isFinite(limitArg) && limitArg > 0 ? Math.trunc(limitArg) : Infinity;
|
|
41
|
+
|
|
42
|
+
const tally = Object.fromEntries(SESSION_TYPES.map((t) => [t, 0]));
|
|
43
|
+
let scanned = 0;
|
|
44
|
+
let written = 0;
|
|
45
|
+
|
|
46
|
+
// Paged rather than one big SELECT: `detail` is a jsonb per row and the corpus
|
|
47
|
+
// is the largest table this module owns, so pulling it whole would hold the
|
|
48
|
+
// entire transcript corpus in memory at once for no gain.
|
|
49
|
+
for (;;) {
|
|
50
|
+
const room = Math.min(BATCH, cap - scanned);
|
|
51
|
+
if (room <= 0) break;
|
|
52
|
+
const rows = await db.unclassifiedSessions(room);
|
|
53
|
+
if (rows.length === 0) break;
|
|
54
|
+
for (const row of rows) {
|
|
55
|
+
const type = classifySession({ detail: row.detail, metrics: row.metrics });
|
|
56
|
+
tally[type] = (tally[type] || 0) + 1;
|
|
57
|
+
scanned++;
|
|
58
|
+
if (apply) written += await db.setSessionType(row.id, type);
|
|
59
|
+
}
|
|
60
|
+
// A dry run never writes, so the same unclassified page comes back forever.
|
|
61
|
+
// One page is enough to show the shape; say so rather than looping.
|
|
62
|
+
if (!apply) break;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log(`\nsession-type backfill — ${apply ? 'APPLIED' : 'DRY RUN (no writes)'}`);
|
|
66
|
+
console.log(` rows classified ${scanned}`);
|
|
67
|
+
if (apply) console.log(` rows written ${written}`);
|
|
68
|
+
console.log(` distribution:`);
|
|
69
|
+
for (const t of SESSION_TYPES) {
|
|
70
|
+
const n = tally[t] || 0;
|
|
71
|
+
const pct = scanned ? ` (${Math.round((n / scanned) * 100)}%)` : '';
|
|
72
|
+
console.log(` ${t.padEnd(14)} ${String(n).padStart(6)}${pct}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (apply) {
|
|
76
|
+
console.log(`\n corpus now:`);
|
|
77
|
+
for (const r of await db.sessionTypeCounts()) {
|
|
78
|
+
console.log(` ${String(r.type).padEnd(14)} ${String(r.n).padStart(6)}`);
|
|
79
|
+
}
|
|
80
|
+
} else if (scanned > 0) {
|
|
81
|
+
console.log(`\n dry run reads one page of ${BATCH}; re-run with --apply to write and walk the whole corpus.`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main()
|
|
86
|
+
.then(() => process.exit(0))
|
|
87
|
+
.catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/bug-attachment-audit.js — do the images our task descriptions
|
|
3
|
+
// promise actually exist? (task 1003918)
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS EXISTS. A re-hosted #bugs screenshot is the one artefact in this
|
|
6
|
+
// system that cannot be regenerated: the Discord CDN URL it was copied from is
|
|
7
|
+
// signed and expires in about a day, so our copy IS the image. They live on disk
|
|
8
|
+
// under var/bug-attachments — outside Postgres, outside git — and nothing
|
|
9
|
+
// noticed when all five this instance had ever stored disappeared in the
|
|
10
|
+
// 2026-07-05 droplet move. The loss surfaced on 2026-09-14, two months later,
|
|
11
|
+
// because somebody happened to open an old bug task and saw a broken image.
|
|
12
|
+
//
|
|
13
|
+
// So: read the descriptions, extract every attachment link they promise, and ask
|
|
14
|
+
// the disk whether it can keep that promise. A count of broken promises is the
|
|
15
|
+
// thing that should have been non-zero and visible in July.
|
|
16
|
+
//
|
|
17
|
+
// Usage:
|
|
18
|
+
// node scripts/gds/bug-attachment-audit.js # human report; exit 1 if any are missing
|
|
19
|
+
// node scripts/gds/bug-attachment-audit.js --json # { described, present, missing: [...] }
|
|
20
|
+
// node scripts/gds/bug-attachment-audit.js --line # one line, or nothing; always exit 0
|
|
21
|
+
//
|
|
22
|
+
// Read-only. It never writes, never deletes, and never touches Discord.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
// The link shape the bot embeds in a description. Deliberately matched against
|
|
30
|
+
// the SERVE ROUTE's path rather than any filename found on disk: what matters is
|
|
31
|
+
// what a reader will click, and a file nothing links to is not a broken promise.
|
|
32
|
+
const LINK_RE = /\/api\/(?:gds|bongos)\/bug-attachments\/(bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(?:png|jpg|gif|webp))/gi;
|
|
33
|
+
|
|
34
|
+
// PURE — every attachment name a body of text promises, deduped, in first-seen
|
|
35
|
+
// order. Exported so the extraction is testable without a database or a disk.
|
|
36
|
+
function describedAttachments(text) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const m of String(text || '').matchAll(LINK_RE)) {
|
|
40
|
+
const name = m[1];
|
|
41
|
+
if (!seen.has(name)) { seen.add(name); out.push(name); }
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// PURE — split the promised names into kept and broken, given an existence test.
|
|
47
|
+
// `rows` are { id, title, description }; the result names the TASK for each
|
|
48
|
+
// missing file, because "three files are gone" is not actionable and "the image
|
|
49
|
+
// in task N is gone" is.
|
|
50
|
+
function auditRows(rows, exists) {
|
|
51
|
+
const described = [];
|
|
52
|
+
const missing = [];
|
|
53
|
+
for (const row of rows || []) {
|
|
54
|
+
for (const name of describedAttachments(row && row.description)) {
|
|
55
|
+
described.push({ name, taskId: row.id });
|
|
56
|
+
if (!exists(name)) missing.push({ name, taskId: row.id, title: row.title || '' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { described, missing, present: described.length - missing.length };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// EVERY status, explicitly, and paged. Two ways to get a comfortable zero here,
|
|
63
|
+
// both of which I hit while building this:
|
|
64
|
+
//
|
|
65
|
+
// - GET /tasks omits abandoned rows unless you ask for them. The five files this
|
|
66
|
+
// audit exists for are described in tasks that are now abandoned or shipped, so
|
|
67
|
+
// the obvious query returned '0 linked, 0 missing' — indistinguishable from
|
|
68
|
+
// 'nothing is broken'.
|
|
69
|
+
// - the route clamps `limit`, so one page is not the table.
|
|
70
|
+
//
|
|
71
|
+
// A probe whose failure mode is a clean bill of health is worse than no probe, so
|
|
72
|
+
// the status list is spelled out rather than defaulted.
|
|
73
|
+
// 'claimed' is not one — claimTask sets status='active' (db-claims.js). Listing it
|
|
74
|
+
// only bought a no-op query, and a comment promising EVERY status should be right.
|
|
75
|
+
const TASK_STATUSES = ['backlog', 'ready', 'active', 'completed', 'confirmed', 'shipped', 'abandoned'];
|
|
76
|
+
|
|
77
|
+
async function fetchRows() {
|
|
78
|
+
const { cliClient } = require('./cli-lib');
|
|
79
|
+
const { TASK_LIST_MAX_LIMIT, buildTaskPageQuery } = require('../../modules/lifecycle/task-list-limits.js');
|
|
80
|
+
const api = await cliClient();
|
|
81
|
+
const byId = new Map();
|
|
82
|
+
for (const status of TASK_STATUSES) {
|
|
83
|
+
let offset = 0;
|
|
84
|
+
for (let page = 0; page < 100; page++) {
|
|
85
|
+
const q = buildTaskPageQuery({ kind: 'bug', status, limit: TASK_LIST_MAX_LIMIT, offset });
|
|
86
|
+
const r = await api.tasks.getTasks({ query: q });
|
|
87
|
+
if (!r || !r.ok) throw new Error(`could not list ${status} tasks (${r && r.status})`);
|
|
88
|
+
const rows = (r.data && r.data.tasks) || [];
|
|
89
|
+
for (const t of rows) byId.set(String(t.id), t);
|
|
90
|
+
// A short page is the end. Compare against the limit the route HONOURED.
|
|
91
|
+
if (rows.length < q.limit) break;
|
|
92
|
+
offset += q.limit;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...byId.values()];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main() {
|
|
99
|
+
const argv = process.argv.slice(2);
|
|
100
|
+
const dir = require('../../modules/discord/bug-attachments.js').attachmentsDir();
|
|
101
|
+
let rows;
|
|
102
|
+
try {
|
|
103
|
+
rows = await fetchRows();
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (argv.includes('--line')) process.exit(0); // a probe never blocks a session
|
|
106
|
+
console.error(`bug-attachment-audit: undetermined — ${e.message || e}`);
|
|
107
|
+
process.exit(2);
|
|
108
|
+
}
|
|
109
|
+
// Through the serve route's OWN resolver, not a bare path.join. The name pattern
|
|
110
|
+
// already excludes slashes and dots, and a test pins that '../../etc/passwd' is
|
|
111
|
+
// never extracted — but the route that actually opens these files defends itself
|
|
112
|
+
// with a resolved-path prefix check, and a probe that reads the same store should
|
|
113
|
+
// not hold itself to a weaker rule than the thing it audits.
|
|
114
|
+
const { resolveSafe } = require('../../modules/discord/bug-attachments.js');
|
|
115
|
+
const r = auditRows(rows, (name) => {
|
|
116
|
+
const p = resolveSafe(name);
|
|
117
|
+
return Boolean(p) && fs.existsSync(p);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
if (argv.includes('--json')) {
|
|
121
|
+
console.log(JSON.stringify({ dir, described: r.described.length, present: r.present, missing: r.missing }, null, 2));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (argv.includes('--line')) {
|
|
125
|
+
if (r.missing.length) {
|
|
126
|
+
console.log(`Bug attachments: ${r.missing.length} of ${r.described.length} linked screenshot(s) are MISSING from ${dir} `
|
|
127
|
+
+ '— every task describing one renders a broken image. These cannot be re-fetched (the Discord URL has expired).');
|
|
128
|
+
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
console.log(` Bug attachments: ${r.described.length} linked, ${r.present} present, ${r.missing.length} missing`);
|
|
132
|
+
console.log(` Store: ${dir}`);
|
|
133
|
+
for (const m of r.missing) console.log(` missing ${m.name} (task ${m.taskId})`);
|
|
134
|
+
if (r.missing.length) {
|
|
135
|
+
console.log(' These cannot be re-fetched — the Discord CDN URL they were copied from has expired.');
|
|
136
|
+
console.log(' Restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (require.main === module) main();
|
|
142
|
+
|
|
143
|
+
module.exports = { describedAttachments, auditRows, LINK_RE };
|
package/scripts/gds/capture.js
CHANGED
|
@@ -282,6 +282,25 @@ async function main() {
|
|
|
282
282
|
const noPrompt = hasFlag('--no-prompt', args);
|
|
283
283
|
const goalArg = arg('--goal', args);
|
|
284
284
|
|
|
285
|
+
// A BAD --kind used to reach the server and come back a 400 (task 1003694).
|
|
286
|
+
// That is not merely late, it is invisible where it matters: a SCHEDULED routine
|
|
287
|
+
// filing its findings gets the 400 into a log nobody reads, the audit work still
|
|
288
|
+
// runs, and only the step that turns a finding into a durable row fails — so no
|
|
289
|
+
// queue accumulates to make the absence noticeable. dep-audit-nightly filed with
|
|
290
|
+
// `--kind security`, which is not a kind, and had therefore NEVER filed anything.
|
|
291
|
+
//
|
|
292
|
+
// Refusing here makes the same typo surface at authoring time, on the machine of
|
|
293
|
+
// whoever wrote the command, with the valid list in the message.
|
|
294
|
+
if (kindFromArg !== null) {
|
|
295
|
+
const { VALID_KINDS } = require('../../modules/ideas/inbox.js');
|
|
296
|
+
if (!VALID_KINDS.includes(kindFromArg)) {
|
|
297
|
+
console.error(`--kind "${kindFromArg}" is not a valid kind. The server would refuse this with a 400.`);
|
|
298
|
+
console.error(`Valid kinds: ${VALID_KINDS.join(', ')}`);
|
|
299
|
+
console.error('Adding a new kind is a schema change (the idea_inbox CHECK constraint) and needs its own migration + ADR.');
|
|
300
|
+
process.exit(2);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
285
304
|
if (parseGoalArg(goalArg).kind === 'invalid') {
|
|
286
305
|
console.error(`--goal must be a positive goal id, or the literal "none" to file homeless (got "${goalArg}").`);
|
|
287
306
|
process.exit(2);
|
|
@@ -170,6 +170,42 @@ if [ "$BYTES" -gt "$WARN_BYTES" ]; then
|
|
|
170
170
|
echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN latest dump is ${BYTES} bytes (> ${WARN_BYTES} threshold) — review what grew; is it a regenerable table excludable like search_chunks?" >&2
|
|
171
171
|
fi
|
|
172
172
|
|
|
173
|
+
# ---- bug attachments (task 1003918) -----------------------------------------
|
|
174
|
+
|
|
175
|
+
# The DB dump above is not the whole state. Re-hosted #bugs screenshots live on
|
|
176
|
+
# disk under var/bug-attachments, outside Postgres and outside git, and they are
|
|
177
|
+
# the one artefact here that CANNOT be regenerated: the Discord CDN URL they were
|
|
178
|
+
# copied from is signed and expires in about a day, so our copy IS the image.
|
|
179
|
+
#
|
|
180
|
+
# Nothing backed them up. All five this instance ever stored are gone — the
|
|
181
|
+
# 2026-07-05 droplet move carried the database and not var/ — and every task
|
|
182
|
+
# description embedding one now renders a broken image in the hall.
|
|
183
|
+
#
|
|
184
|
+
# Tarred beside the dump, same timestamp, same retention, so a restore drill that
|
|
185
|
+
# picks a date gets the database and the images that go with it. Best-effort by
|
|
186
|
+
# design: a missing or empty attachments dir is the ordinary case on a fresh
|
|
187
|
+
# instance and must never fail a night of database backup.
|
|
188
|
+
ATTACH_DIR="${BUG_ATTACHMENTS_DIR:-$REPO_ROOT/var/bug-attachments}"
|
|
189
|
+
ATTACH_OUT="${BACKUP_DIR}/${DB_NAME}-attachments-${TS}.tar.gz"
|
|
190
|
+
ATTACH_GLOB="${DB_NAME}-attachments-${D}${D}${D}${D}-${D}${D}-${D}${D}T${D}${D}${D}${D}${D}${D}Z.tar.gz"
|
|
191
|
+
|
|
192
|
+
if [ -d "$ATTACH_DIR" ]; then
|
|
193
|
+
ATTACH_COUNT=$(find "$ATTACH_DIR" -maxdepth 1 -type f | wc -l)
|
|
194
|
+
if [ "$ATTACH_COUNT" -gt 0 ]; then
|
|
195
|
+
if tar -czf "$ATTACH_OUT.partial" -C "$ATTACH_DIR" . 2>/dev/null; then
|
|
196
|
+
mv "$ATTACH_OUT.partial" "$ATTACH_OUT"
|
|
197
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: wrote ${ATTACH_OUT} (${ATTACH_COUNT} attachment(s))"
|
|
198
|
+
else
|
|
199
|
+
rm -f "$ATTACH_OUT.partial"
|
|
200
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: WARN could not archive ${ATTACH_DIR} — database dump is unaffected" >&2
|
|
201
|
+
fi
|
|
202
|
+
else
|
|
203
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug attachments to archive"
|
|
204
|
+
fi
|
|
205
|
+
else
|
|
206
|
+
echo "[$(date -u +%FT%TZ)] gds-db-backup: no bug-attachment dir at ${ATTACH_DIR} — nothing to archive"
|
|
207
|
+
fi
|
|
208
|
+
|
|
173
209
|
# ---- retention prune --------------------------------------------------------
|
|
174
210
|
|
|
175
211
|
# Delete dumps older than RETENTION_DAYS. -mtime +N means strictly older
|
|
@@ -177,7 +213,7 @@ fi
|
|
|
177
213
|
# Done AFTER the new dump lands so a streak of failed runs can't erase
|
|
178
214
|
# everything: we never delete on a day we didn't successfully dump.
|
|
179
215
|
PRUNED=$(find "$BACKUP_DIR" -maxdepth 1 -type f \
|
|
180
|
-
-name "$DUMP_GLOB" \
|
|
216
|
+
\( -name "$DUMP_GLOB" -o -name "$ATTACH_GLOB" \) \
|
|
181
217
|
-mtime +"${RETENTION_DAYS}" \
|
|
182
218
|
-print -delete | wc -l)
|
|
183
219
|
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -200,9 +200,19 @@ function checkGeneratedArtifactsFresh() {
|
|
|
200
200
|
} catch {
|
|
201
201
|
violations.push('docs/api/openapi.json + docs/api-reference.md + docs/routes-permissions.md are STALE (or the spec is not valid OpenAPI 3.1) — run `node scripts/gds/gen-api-docs.js` and commit (task 1918).');
|
|
202
202
|
}
|
|
203
|
+
try {
|
|
204
|
+
// task 1003556: the copy registry's rows carry LINE NUMBERS, so editing any scanned
|
|
205
|
+
// UI file stales it. It was covered only by ship-check's OPTIONAL step, which a
|
|
206
|
+
// builder has to remember to run — so the first signal was CI failing on the PR's
|
|
207
|
+
// MERGE ref, where the shift is hardest to attribute. Gated here so a local fitness
|
|
208
|
+
// run says so first.
|
|
209
|
+
execFileSync(process.execPath, [path.join(__dirname, 'copy-inventory.js'), '--check'], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
210
|
+
} catch {
|
|
211
|
+
violations.push('docs/copy-registry.json + docs/copy-inventory.md are STALE — every row carries a line number, so editing any scanned UI file shifts them. Run `node scripts/gds/copy-inventory.js` and commit (task 1003556).');
|
|
212
|
+
}
|
|
203
213
|
// NB the role packs' responsibility blocks are generated too, but their freshness
|
|
204
214
|
// is asserted by role-pack-guard.js — one check owns "the packs are correct".
|
|
205
|
-
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections
|
|
215
|
+
return { name: 'generated artifacts fresh', ok: violations.length === 0, hardFail: violations.length > 0, violations, warnings: [], note: 'Tracked generated content matches source: nested CLAUDE.md symbol blocks (code) + the CLAUDE.md §13 snippet (sessions) via --check-block, plus the CLAUDE.md §2 interaction-posture block (the knob defaults), docs/file-map.md skills/scheduled-tasks sections, docs/api/openapi.json + api-reference.md (API) and docs/copy-registry.json + copy-inventory.md (the reader-facing copy, task 1003556). The gitignored docs/repo-map.md + docs/session-log-index.md are deploy-regenerated (ADR 0110), not gated here.' };
|
|
206
216
|
}
|
|
207
217
|
|
|
208
218
|
// ---- deterministic cron routine boundary (System 4 / ADR 0078) -------------
|
|
@@ -1223,6 +1233,7 @@ const CHECKS = [
|
|
|
1223
1233
|
// crashes later, so a start-up refusal would trade an outage risk for a cosmetic
|
|
1224
1234
|
// one (ADR 0289). Rationale + the seam→consumer table live in that file.
|
|
1225
1235
|
require('./seam-wiring-guard.js').checkSeamWiring,
|
|
1236
|
+
require('./home-isolation-guard.js').checkHomeIsolation, // Check 34 — task 1003763: a test that sets HOME for a child without USERPROFILE isolates NOTHING on win32, so it silently stops testing what its comment says. Three tasks fixed this one site at a time. Rationale in that file.
|
|
1226
1237
|
require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
|
|
1227
1238
|
];
|
|
1228
1239
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/home-isolation-guard.js — setting HOME for a child process isolates
|
|
4
|
+
// nothing on Windows (task 1003763).
|
|
5
|
+
//
|
|
6
|
+
// THE DEFECT, three times now. os.homedir() reads USERPROFILE on win32 and never
|
|
7
|
+
// consults HOME, so `env: { ...process.env, HOME: tmp }` leaves the child looking at
|
|
8
|
+
// the DEVELOPER'S REAL home — and therefore their real CLI session.
|
|
9
|
+
//
|
|
10
|
+
// tests/cli_sessions.mjs (task 1003760) — DESTROYED the developer's live session.
|
|
11
|
+
// tests/cli_surface.mjs (task 1003941) — guidance printers took the authenticated
|
|
12
|
+
// branch, so the test failed for a false
|
|
13
|
+
// reason on any signed-in Windows box.
|
|
14
|
+
// tests/cli_package.mjs (task 1003763) — an unauthenticated-path assertion could
|
|
15
|
+
// pass because the machine was signed in.
|
|
16
|
+
//
|
|
17
|
+
// The last two are the dangerous shape: a test that isolates nothing does not
|
|
18
|
+
// announce itself, it just quietly stops testing what its comment says it tests.
|
|
19
|
+
//
|
|
20
|
+
// WHY A GUARD AND NOT THREE FIXES. Three sites, three separate tasks, each found by
|
|
21
|
+
// accident. tests/memory_pull.mjs, tests/redteam_patrol.mjs and
|
|
22
|
+
// tests/setup_device_flow_fallback.mjs already do it correctly, so the convention
|
|
23
|
+
// exists and is simply easy to miss — which is the definition of something CI should
|
|
24
|
+
// hold rather than a reviewer.
|
|
25
|
+
|
|
26
|
+
const fs = require('node:fs');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
30
|
+
const TESTS_DIR = path.join(REPO_ROOT, 'tests');
|
|
31
|
+
|
|
32
|
+
// WHAT COUNTS AS A SPAWN ENV. Only a literal that SPREADS process.env and assigns
|
|
33
|
+
// HOME — that is the shape every real defect had. Two narrowings, both learned by
|
|
34
|
+
// getting this wrong on the first run against the tree:
|
|
35
|
+
//
|
|
36
|
+
// - COMMENTS ARE STRIPPED. All three fixed files now explain the bug by QUOTING
|
|
37
|
+
// the broken line, so a raw text scan reports the very files that fixed it.
|
|
38
|
+
// - A HELPER'S CALL SITE IS NOT A SPAWN ENV. tests/setup_smoke.mjs passes
|
|
39
|
+
// `{ HOME: fakeHome }` to its own runSetup(), which adds USERPROFILE itself.
|
|
40
|
+
// Requiring `...process.env` in the same literal distinguishes 'building a
|
|
41
|
+
// child's environment' from 'passing an option to a function', and flags the
|
|
42
|
+
// helper rather than each of its callers — which is where the fix belongs.
|
|
43
|
+
const HOME_ASSIGN_RE = /(^|[{,\s])HOME\s*:/;
|
|
44
|
+
const USERPROFILE_RE = /(^|[{,\s])USERPROFILE\s*:/;
|
|
45
|
+
const PROCESS_ENV_RE = /\.\.\.process\.env/;
|
|
46
|
+
|
|
47
|
+
// Strip line and block comments so prose ABOUT the bug is not read as the bug.
|
|
48
|
+
// Errs toward keeping text (a guard should fail loud, not quiet): only the two
|
|
49
|
+
// unambiguous comment forms are removed.
|
|
50
|
+
function stripComments(src) {
|
|
51
|
+
return String(src || '')
|
|
52
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
53
|
+
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Files where HOME is the SUBJECT rather than an isolation mechanism, named in task
|
|
57
|
+
// 1003763. An allowlist entry is a claim that the file means something else by HOME,
|
|
58
|
+
// so each one says which.
|
|
59
|
+
const ALLOW = new Map([
|
|
60
|
+
['scrubber_corpus.mjs', "HOME:'/root' is literal corpus DATA — a string the scrubber is asked to redact, not an env handed to a child"],
|
|
61
|
+
['lib_sh_resolution.mjs', 'exercises POSIX shell HOME resolution; HOME is the thing under test, and a win32 variable would change the subject'],
|
|
62
|
+
['home_isolation_guard.mjs', "this guard's OWN tests carry the broken shape as fixture strings — it must not report its own corpus, the same way category-advisory-guard.js had to stop matching its own filename"],
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
// PURE — the lines that build a child env with HOME and no USERPROFILE. Exported so
|
|
66
|
+
// the matching is testable without the tree: a scanner whose pattern silently stops
|
|
67
|
+
// matching reports a clean repo, which is the failure mode this whole file is about.
|
|
68
|
+
function offendingLines(src) {
|
|
69
|
+
const lines = stripComments(src).split('\n');
|
|
70
|
+
const out = [];
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
if (!HOME_ASSIGN_RE.test(lines[i])) continue;
|
|
73
|
+
// A literal can wrap: read a small window either side for its companions.
|
|
74
|
+
const window = lines.slice(Math.max(0, i - 4), i + 5).join('\n');
|
|
75
|
+
if (!PROCESS_ENV_RE.test(window)) continue; // an option object, not a child env
|
|
76
|
+
if (USERPROFILE_RE.test(window)) continue; // already isolated properly
|
|
77
|
+
out.push({ line: i + 1, text: lines[i].trim() });
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
function checkHomeIsolation() {
|
|
82
|
+
const name = 'a child env that sets HOME also sets USERPROFILE (task 1003763)';
|
|
83
|
+
let names = [];
|
|
84
|
+
try {
|
|
85
|
+
names = fs.readdirSync(TESTS_DIR).filter((f) => f.endsWith('.mjs'));
|
|
86
|
+
} catch (e) {
|
|
87
|
+
return { name, ok: true, hardFail: false, violations: [], warnings: [`tests/ unreadable: ${e.message}`], note: 'skipped.' };
|
|
88
|
+
}
|
|
89
|
+
// The scan must not pass by finding nothing.
|
|
90
|
+
if (names.length === 0) {
|
|
91
|
+
return {
|
|
92
|
+
name, ok: false, hardFail: true,
|
|
93
|
+
violations: ['no tests/*.mjs found at all — that is a broken scan, not a clean one'],
|
|
94
|
+
warnings: [], note: 'the scan produced an empty file set.',
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const violations = [];
|
|
99
|
+
let scanned = 0;
|
|
100
|
+
let sitesSeen = 0;
|
|
101
|
+
for (const n of names) {
|
|
102
|
+
const src = fs.readFileSync(path.join(TESTS_DIR, n), 'utf8');
|
|
103
|
+
scanned += 1;
|
|
104
|
+
const hits = offendingLines(src);
|
|
105
|
+
if (HOME_ASSIGN_RE.test(stripComments(src)) && PROCESS_ENV_RE.test(stripComments(src))) sitesSeen += 1;
|
|
106
|
+
if (ALLOW.has(n)) continue;
|
|
107
|
+
for (const h of hits) {
|
|
108
|
+
violations.push(`tests/${n}:${h.line} sets HOME for a child without USERPROFILE — \`${h.text.slice(0, 90)}\`. `
|
|
109
|
+
+ 'On Windows os.homedir() reads USERPROFILE and ignores HOME, so this isolates NOTHING: the child sees the '
|
|
110
|
+
+ 'developer\'s real home and real session, and an unauthenticated-path assertion can pass because the machine '
|
|
111
|
+
+ 'happens to be signed in. Set USERPROFILE (and HOMEDRIVE/HOMEPATH) to the same directory.');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name,
|
|
117
|
+
ok: violations.length === 0,
|
|
118
|
+
hardFail: violations.length > 0,
|
|
119
|
+
violations,
|
|
120
|
+
warnings: [],
|
|
121
|
+
note: `${scanned} test file(s) scanned, ${sitesSeen} of them build a child env with HOME; ${ALLOW.size} allowlisted where HOME is the subject rather than the isolation. `
|
|
122
|
+
+ 'Three separate tasks (1003760, 1003941, 1003763) fixed this one site at a time before it was worth a check.',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { checkHomeIsolation, offendingLines, stripComments, ALLOW, HOME_ASSIGN_RE, USERPROFILE_RE, PROCESS_ENV_RE };
|
|
@@ -27,7 +27,7 @@ const {
|
|
|
27
27
|
} = require('./ship-land.js');
|
|
28
28
|
const { runSmoke } = require('./ship-grade.js');
|
|
29
29
|
const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
|
|
30
|
-
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
30
|
+
const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
|
|
31
31
|
const { REPO_ROOT } = require('./ship-state.js');
|
|
32
32
|
|
|
33
33
|
// zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
|
|
@@ -119,6 +119,10 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
119
119
|
regenerateSessionIndex();
|
|
120
120
|
// Refresh the generated file-map sections (skills + scheduled-tasks) on the same beat (ADR 0066 / task 1276).
|
|
121
121
|
regenerateFileMap();
|
|
122
|
+
// Refresh the copy registry + inventory on the same beat (task 1003556). Its rows
|
|
123
|
+
// carry line numbers, so ANY edit to a scanned UI file staled it — and nothing in
|
|
124
|
+
// the ship path regenerated it, so the PR was born stale and CI said so.
|
|
125
|
+
regenerateCopyInventory();
|
|
122
126
|
// Refresh the generated OpenAPI spec + API reference from the shipped route tree (task 1918).
|
|
123
127
|
regenerateApiDocs();
|
|
124
128
|
// ...then the typed client FROM that spec (+ the devbox vendored copy) on the same beat (task 2051).
|
|
@@ -235,6 +235,34 @@ function regenerateFileMap() {
|
|
|
235
235
|
return 'committed-nofatal';
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
+
// docs/copy-registry.json + docs/copy-inventory.md — the reader-facing copy the UI
|
|
239
|
+
// actually renders (task 1003556).
|
|
240
|
+
//
|
|
241
|
+
// WHY IT HAS TO RUN HERE. Every row in the registry carries a `line`, so editing ANY
|
|
242
|
+
// scanned UI file shifts line numbers and makes tests/copy_inventory.mjs red — on the
|
|
243
|
+
// PR's MERGE ref, not on the branch, which is the worst place to find out. Nothing in
|
|
244
|
+
// the ship path regenerated it (grep for copy-inventory across ship.js, ship-regen.js
|
|
245
|
+
// and ship-preflight-steps.js used to return zero hits) and fitness did not cover the
|
|
246
|
+
// pair either, so the builder got no local signal at all and learned from CI.
|
|
247
|
+
//
|
|
248
|
+
// Task 1003546 fixed the SERVER-side merge heals for the same artifact. Those repair a
|
|
249
|
+
// strand after it happens; this is the prevention, so the PR is not born stale.
|
|
250
|
+
function regenerateCopyInventory() {
|
|
251
|
+
console.log(' regenerating the copy registry + inventory from the shipped tree...');
|
|
252
|
+
shellOk(`"${process.execPath}" ${JSON.stringify(path.join(__dirname, 'copy-inventory.js'))}`);
|
|
253
|
+
const dirty = gitOk('git status --porcelain docs/copy-registry.json docs/copy-inventory.md');
|
|
254
|
+
if (!dirty) {
|
|
255
|
+
console.log(' copy registry already current — nothing to commit');
|
|
256
|
+
return 'current';
|
|
257
|
+
}
|
|
258
|
+
shellOk('git add docs/copy-registry.json docs/copy-inventory.md');
|
|
259
|
+
if (shellOk('git commit -m "chore(copy): auto-regenerate the copy registry + inventory (task 1003556)"')) {
|
|
260
|
+
console.log(' committed auto-regenerated copy registry');
|
|
261
|
+
return 'committed';
|
|
262
|
+
}
|
|
263
|
+
return 'committed-nofatal';
|
|
264
|
+
}
|
|
265
|
+
|
|
238
266
|
// ---------- merge serialization lock (#462 C4; extended in #269 C6) ----------
|
|
239
267
|
//
|
|
240
268
|
// autoMerge() mutates the SHARED main worktree (REPO_ROOT). Two concurrent
|
|
@@ -313,6 +341,7 @@ module.exports = {
|
|
|
313
341
|
regenerateApiClient,
|
|
314
342
|
regenerateApiDocs,
|
|
315
343
|
regenerateDiagrams,
|
|
344
|
+
regenerateCopyInventory,
|
|
316
345
|
regenerateFileMap,
|
|
317
346
|
regenerateRepoMap,
|
|
318
347
|
regenerateSessionIndex,
|
|
@@ -120,13 +120,36 @@ const CAPABILITIES = {
|
|
|
120
120
|
},
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
// EVERY read of a SKILL.md goes through here (task 1003754).
|
|
124
|
+
//
|
|
125
|
+
// These files are CRLF in a Windows checkout — .gitattributes pins *.js, *.json,
|
|
126
|
+
// *.sh and *.sql, but Markdown falls through `* text=auto`. Every regex below is
|
|
127
|
+
// anchored on \n, starting with the frontmatter fence /^---\n/, so on Windows the
|
|
128
|
+
// frontmatter simply did not match and parseRequires returned `declared: false`
|
|
129
|
+
// for EVERY skill.
|
|
130
|
+
//
|
|
131
|
+
// That is worse than one red test. Fitness check G03 compares what a skill's fenced
|
|
132
|
+
// commands imply against what it declares — and with nothing parsed as declared, the
|
|
133
|
+
// check reported '53 skill(s) scanned; 0 declare a machine contract' and had nothing
|
|
134
|
+
// to compare. The guard against a skill's ops commands outrunning its declaration
|
|
135
|
+
// was inert on any Windows box.
|
|
136
|
+
//
|
|
137
|
+
// Normalising once at the boundary is deliberately not 'make each regex CRLF-aware':
|
|
138
|
+
// there are six of them and the next one added would forget.
|
|
139
|
+
function readSkillMd(p) {
|
|
140
|
+
return String(fs.readFileSync(p, 'utf8')).replace(/\r\n/g, '\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
// ---- the declaration ------------------------------------------------------
|
|
124
144
|
// Read `requires:` out of a skill's YAML frontmatter. Accepts the inline form
|
|
125
145
|
// (`requires: [gh, main-checkout]`) and the dash-list form. Returns
|
|
126
146
|
// { requires, declared, error }: `declared` distinguishes "declared nothing"
|
|
127
147
|
// from "declared an empty list", which the fitness check needs to tell apart.
|
|
128
148
|
function parseRequires(md) {
|
|
129
|
-
|
|
149
|
+
// Normalised here too, not only in readSkillMd: this is exported and a caller may
|
|
150
|
+
// hand it raw file text (the fitness check's injected `read` does exactly that).
|
|
151
|
+
const src = String(md || '').replace(/\r\n/g, '\n');
|
|
152
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(src);
|
|
130
153
|
if (!m) return { requires: [], declared: false, error: null };
|
|
131
154
|
const fm = m[1];
|
|
132
155
|
const inline = /^requires:[ \t]*\[(.*?)\][ \t]*$/m.exec(fm);
|
|
@@ -176,7 +199,8 @@ function fencedCode(md) {
|
|
|
176
199
|
}
|
|
177
200
|
|
|
178
201
|
function inferRequired(md) {
|
|
179
|
-
|
|
202
|
+
// Same reason as parseRequires: the SIGNALS below are ^-anchored per line.
|
|
203
|
+
const code = fencedCode(String(md || '').replace(/\r\n/g, '\n'));
|
|
180
204
|
const out = new Set();
|
|
181
205
|
for (const [rx, cap] of SIGNALS) if (rx.test(code)) out.add(cap);
|
|
182
206
|
return [...out].sort();
|
|
@@ -224,7 +248,7 @@ function preflightSkill(name, caps = CAPABILITIES) {
|
|
|
224
248
|
return { ok: false, requires: [], missing: [{ name: 'skill', why: `"${name}" is not a skill name — it must be a single directory name under .claude/skills/.` }], unknown: [], checked: [] };
|
|
225
249
|
}
|
|
226
250
|
let md;
|
|
227
|
-
try { md =
|
|
251
|
+
try { md = readSkillMd(p); } catch {
|
|
228
252
|
return { ok: false, missing: [{ name: 'skill', why: `no skill named "${name}" at .claude/skills/${name}/SKILL.md` }], unknown: [], checked: [] };
|
|
229
253
|
}
|
|
230
254
|
const { requires, error } = parseRequires(md);
|
|
@@ -263,7 +287,7 @@ function checkOpsSkillsDeclareMachine({ dir = SKILLS_DIR, read = null, names: in
|
|
|
263
287
|
};
|
|
264
288
|
}
|
|
265
289
|
const readOne = read || ((n) => {
|
|
266
|
-
try { return
|
|
290
|
+
try { return readSkillMd(path.join(dir, n, 'SKILL.md')); } catch { return null; }
|
|
267
291
|
});
|
|
268
292
|
|
|
269
293
|
let declaring = 0;
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.820'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|