@bongos/core 1.19.525 → 1.19.527
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 +89 -49
- package/.claude/skills/blocker-solve/SKILL.md +2 -1
- package/.claude/skills/builder-box/SKILL.md +2 -1
- package/.claude/skills/builder-connect/SKILL.md +2 -1
- package/.claude/skills/builder-end/SKILL.md +2 -1
- package/.claude/skills/builder-exit/SKILL.md +2 -1
- package/.claude/skills/builder-key/SKILL.md +2 -1
- package/.claude/skills/builder-sequence/SKILL.md +2 -1
- package/.claude/skills/builder-stage/SKILL.md +2 -1
- package/.claude/skills/design/SKILL.md +2 -1
- package/.claude/skills/goal-create/SKILL.md +2 -1
- package/.claude/skills/goal-review/SKILL.md +2 -1
- package/.claude/skills/grade-audit/SKILL.md +2 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/ideate/SKILL.md +2 -1
- package/.claude/skills/new-project/SKILL.md +2 -1
- package/.claude/skills/otb-character-review/SKILL.md +2 -1
- package/.claude/skills/paint/SKILL.md +2 -1
- package/.claude/skills/scan-before-install/SKILL.md +2 -1
- package/.claude/skills/ship-check/SKILL.md +44 -0
- package/.claude/skills/strand-fix/SKILL.md +2 -1
- package/.claude/skills/worktree-clean/SKILL.md +2 -1
- package/.gitattributes +6 -2
- package/docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md +107 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +4 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +46 -0
- package/modules/grading/generated-provenance.js +1 -1
- package/modules/lifecycle/cascade.js +238 -0
- package/modules/lifecycle/routes/lifecycle.js +25 -0
- package/modules/lifecycle/tests/cascade.mjs +197 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +7 -2
- package/scripts/gds/fitness.js +40 -1
- package/scripts/gds/gen-repo-map.js +3 -1
- package/scripts/gds/session-cost.js +10 -7
- package/scripts/gds/session-digest.js +7 -4
- package/scripts/gds/ship-check.js +78 -0
- package/scripts/gds/ship-regen.js +2 -2
- package/scripts/gds/skill-lint.js +171 -0
- package/scripts/gds/strand-watch.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +10 -0
- package/tests/edit_hints.mjs +78 -0
- package/tests/git_merge_regen.mjs +1 -1
- package/tests/hooks_drain.mjs +8 -2
- package/tests/repo_map.mjs +1 -1
- package/tests/skill_lint.mjs +73 -0
|
@@ -26,11 +26,36 @@
|
|
|
26
26
|
const express = require('express');
|
|
27
27
|
const api = require('../../../src/module-api');
|
|
28
28
|
const lifecycle = require('../lifecycle');
|
|
29
|
+
const db = require('../db');
|
|
30
|
+
const cascade = require('../cascade');
|
|
31
|
+
|
|
32
|
+
// task 1003549 — the cross-role cascade listener. `task.shipped` already exists
|
|
33
|
+
// as a kernel event (emitted post-commit by the publish reconciler and the ship
|
|
34
|
+
// routes, consumed today by discord's broadcast and sessions' deferred reward),
|
|
35
|
+
// so a ship generating work in ANOTHER role needs no new bus: it is one more
|
|
36
|
+
// listener on the event that already says a ship happened. Registered here for
|
|
37
|
+
// the same reason the port is — `mountModuleRoutes` is the one boot hook that
|
|
38
|
+
// runs in the live server AND whenever a test rebuilds the router.
|
|
39
|
+
//
|
|
40
|
+
// The handle is kept so a re-mount replaces the listener instead of stacking a
|
|
41
|
+
// second one (the discord `_shipListenerOff` precedent); a doubled listener
|
|
42
|
+
// would be harmless anyway, since filing is idempotent on (source, source_ref),
|
|
43
|
+
// but "harmless because something downstream catches it" is not a reason to
|
|
44
|
+
// leave it. Nothing here can affect the ship: the emitters fire post-commit and
|
|
45
|
+
// already wrap the emit, and `runCascadesForShip` resolves rather than throws.
|
|
46
|
+
let _cascadeListenerOff = null;
|
|
29
47
|
|
|
30
48
|
function registerSeams() {
|
|
31
49
|
if (!api.hasProvider('lifecycle')) {
|
|
32
50
|
api.registerProvider('lifecycle', lifecycle);
|
|
33
51
|
}
|
|
52
|
+
if (_cascadeListenerOff) _cascadeListenerOff();
|
|
53
|
+
_cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
|
|
54
|
+
getTask: (id) => db.getTask(id),
|
|
55
|
+
findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
|
|
56
|
+
createTask: (spec) => db.createTask(spec),
|
|
57
|
+
log: api.logger ? api.logger('lifecycle') : null,
|
|
58
|
+
}));
|
|
34
59
|
}
|
|
35
60
|
|
|
36
61
|
module.exports = function buildLifecycleRouter() {
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// modules/lifecycle/tests/cascade.mjs — the cross-role cascade (task 1003549 /
|
|
2
|
+
// goal 1000074, the artist's loop).
|
|
3
|
+
//
|
|
4
|
+
// The mechanism's whole value is that a ship in one role can generate work in
|
|
5
|
+
// another WITHOUT ever putting that generation in the ship's way. So the four
|
|
6
|
+
// properties the header of cascade.js promises are what these pin, and three of
|
|
7
|
+
// them are about what must NOT happen:
|
|
8
|
+
//
|
|
9
|
+
// 1. it never blocks the ship — every failure shape resolves to a report
|
|
10
|
+
// 2. it is idempotent — the same ship twice files one task
|
|
11
|
+
// 3. it says where it came from
|
|
12
|
+
// 4. it files at backlog, never ready
|
|
13
|
+
//
|
|
14
|
+
// The production table ships EMPTY (R04 writes the first rule), so the rules
|
|
15
|
+
// below are the test's OWN declarations — which is also the point: a mechanism
|
|
16
|
+
// proved by a rule its consumer has not written yet is proved against the
|
|
17
|
+
// contract rather than against one example.
|
|
18
|
+
//
|
|
19
|
+
// DB-free: the runner takes its three database calls as injected functions.
|
|
20
|
+
|
|
21
|
+
import { strict as assert } from 'node:assert';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import url from 'node:url';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
|
|
26
|
+
const HERE = path.dirname(url.fileURLToPath(import.meta.url));
|
|
27
|
+
const require_ = createRequire(import.meta.url);
|
|
28
|
+
const cascade = require_(path.join(HERE, '..', 'cascade.js'));
|
|
29
|
+
|
|
30
|
+
let passed = 0;
|
|
31
|
+
let failed = 0;
|
|
32
|
+
async function test(name, fn) {
|
|
33
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
34
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.stack || err.message}`); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
console.log('\nlifecycle: a ship in one role generates work in another');
|
|
38
|
+
|
|
39
|
+
const shipped = (over = {}) => ({
|
|
40
|
+
id: '9001',
|
|
41
|
+
version_id: 'BONGOS-V1',
|
|
42
|
+
goal_id: '1000074',
|
|
43
|
+
title: 'R09 · Reword the hall empty states',
|
|
44
|
+
discipline: 'engineer',
|
|
45
|
+
kind: 'feature',
|
|
46
|
+
status: 'shipped',
|
|
47
|
+
...over,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// A rule of the shape R04 will write: an engineer's ship becomes an artist's task.
|
|
51
|
+
const REVIEW_RULE = {
|
|
52
|
+
key: 'test-artist-review',
|
|
53
|
+
discipline: 'artist',
|
|
54
|
+
when: (t) => t.discipline === 'engineer',
|
|
55
|
+
title: (t) => `Review the copy that shipped in task ${t.id}`,
|
|
56
|
+
body: (t) => `Look at what ${t.title} put in front of a person.`,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const stubs = (over = {}) => {
|
|
60
|
+
const created = [];
|
|
61
|
+
return {
|
|
62
|
+
created,
|
|
63
|
+
deps: {
|
|
64
|
+
rules: [REVIEW_RULE],
|
|
65
|
+
getTask: async () => shipped(),
|
|
66
|
+
findTaskBySourceRef: async () => null,
|
|
67
|
+
createTask: async (spec) => { created.push(spec); return { id: String(9100 + created.length) }; },
|
|
68
|
+
...over,
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
await test('an engineer ship files a task FOR THE ARTIST — the push the queues never had', async () => {
|
|
74
|
+
const { created, deps } = stubs();
|
|
75
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
76
|
+
|
|
77
|
+
assert.equal(report.errors.length, 0, report.errors.join(' | '));
|
|
78
|
+
assert.equal(created.length, 1);
|
|
79
|
+
const spec = created[0];
|
|
80
|
+
assert.equal(spec.discipline, 'artist', 'the generated work is for a DIFFERENT role than the ship');
|
|
81
|
+
assert.equal(report.created[0].discipline, 'artist');
|
|
82
|
+
assert.match(spec.title, /9001/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await test('the generated task files at backlog and inherits its goal and version', async () => {
|
|
86
|
+
const { created, deps } = stubs();
|
|
87
|
+
await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
88
|
+
const spec = created[0];
|
|
89
|
+
assert.equal(spec.status, 'backlog', 'generated work is a REQUEST — auto-filing claimable work mints claims nobody asked for');
|
|
90
|
+
assert.equal(spec.goalId, 1000074);
|
|
91
|
+
assert.equal(spec.versionId, 'BONGOS-V1');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('it says where it came from, in the ref and in the prose', async () => {
|
|
95
|
+
const { created, deps } = stubs();
|
|
96
|
+
await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
97
|
+
const spec = created[0];
|
|
98
|
+
assert.equal(spec.source, 'cascade');
|
|
99
|
+
assert.equal(spec.sourceRef, 'cascade-test-artist-review-9001');
|
|
100
|
+
assert.match(spec.description, /task 9001 shipped/);
|
|
101
|
+
assert.match(spec.description, /test-artist-review/);
|
|
102
|
+
assert.match(spec.description, /Look at what/, 'the rule\'s own body survives the preamble');
|
|
103
|
+
// NOT a subtask: cascaded work outlives its trigger and belongs to another role.
|
|
104
|
+
assert.equal(spec.parentTaskId, undefined);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
await test('the same ship twice files ONE task — a reconciler re-sweep is a no-op', async () => {
|
|
108
|
+
const { created, deps } = stubs();
|
|
109
|
+
await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
110
|
+
assert.equal(created.length, 1);
|
|
111
|
+
|
|
112
|
+
// Second pass: the row now exists under the same (source, source_ref).
|
|
113
|
+
deps.findTaskBySourceRef = async ({ source, sourceRef }) => (
|
|
114
|
+
source === 'cascade' && sourceRef === 'cascade-test-artist-review-9001' ? { id: '9101' } : null
|
|
115
|
+
);
|
|
116
|
+
const again = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
117
|
+
assert.equal(created.length, 1, 'no second row');
|
|
118
|
+
assert.equal(again.created.length, 0);
|
|
119
|
+
assert.deepEqual(again.skipped[0], { key: 'test-artist-review', taskId: '9101', reason: 'already filed' });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await test('a rule that does not match files nothing, quietly', async () => {
|
|
123
|
+
const { created, deps } = stubs({ getTask: async () => shipped({ discipline: 'artist' }) });
|
|
124
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
125
|
+
assert.equal(created.length, 0);
|
|
126
|
+
assert.equal(report.errors.length, 0, 'a non-match is not a problem');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --- property 1: nothing here may reach the ship ----------------------------
|
|
130
|
+
|
|
131
|
+
await test('NOTHING a cascade does can fail the ship — every failure shape resolves', async () => {
|
|
132
|
+
const shapes = {
|
|
133
|
+
'when() throws': stubs({ rules: [{ ...REVIEW_RULE, when: () => { throw new Error('boom'); } }] }),
|
|
134
|
+
'title() throws': stubs({ rules: [{ ...REVIEW_RULE, title: () => { throw new Error('boom'); } }] }),
|
|
135
|
+
'createTask rejects': stubs({ createTask: async () => { throw new Error('db down'); } }),
|
|
136
|
+
'findTaskBySourceRef rejects': stubs({ findTaskBySourceRef: async () => { throw new Error('db down'); } }),
|
|
137
|
+
'getTask rejects': stubs({ getTask: async () => { throw new Error('db down'); } }),
|
|
138
|
+
'getTask returns nothing': stubs({ getTask: async () => null }),
|
|
139
|
+
'a malformed rule': stubs({ rules: [{ key: 'broken' }] }),
|
|
140
|
+
};
|
|
141
|
+
for (const [name, { created, deps }] of Object.entries(shapes)) {
|
|
142
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
143
|
+
assert.ok(report && Array.isArray(report.errors), `${name}: returned a report`);
|
|
144
|
+
assert.ok(report.errors.length > 0, `${name}: and said what went wrong`);
|
|
145
|
+
assert.equal(created.length, 0, `${name}: filed nothing`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// The payload itself being junk is the same story.
|
|
149
|
+
for (const junk of [null, undefined, {}, { id: null }]) {
|
|
150
|
+
const { deps } = stubs();
|
|
151
|
+
const report = await cascade.runCascadesForShip(junk, deps);
|
|
152
|
+
assert.ok(report.errors.length > 0, `${JSON.stringify(junk)}: reported rather than threw`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('one broken rule costs only its own task', async () => {
|
|
157
|
+
const good = { ...REVIEW_RULE, key: 'good-one' };
|
|
158
|
+
const bad = { ...REVIEW_RULE, key: 'bad-one', when: () => { throw new Error('boom'); } };
|
|
159
|
+
const { created, deps } = stubs({ rules: [bad, good] });
|
|
160
|
+
const report = await cascade.runCascadesForShip({ id: '9001' }, deps);
|
|
161
|
+
assert.equal(created.length, 1, 'the healthy rule still filed');
|
|
162
|
+
assert.equal(created[0].sourceRef, 'cascade-good-one-9001');
|
|
163
|
+
assert.equal(report.errors.length, 1);
|
|
164
|
+
assert.match(report.errors[0], /bad-one/);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// --- the declaration itself --------------------------------------------------
|
|
168
|
+
|
|
169
|
+
await test('the production table is valid, and a duplicate key is refused', () => {
|
|
170
|
+
assert.deepEqual(cascade.validateCascades(), [], 'the shipped CASCADES table must be well-formed');
|
|
171
|
+
assert.ok(Array.isArray(cascade.CASCADES));
|
|
172
|
+
assert.equal(Object.isFrozen(cascade.CASCADES), true);
|
|
173
|
+
|
|
174
|
+
// Two rules sharing a key would collide on source_ref and silently suppress
|
|
175
|
+
// one another — the failure that is invisible in production.
|
|
176
|
+
const errs = cascade.validateCascades([REVIEW_RULE, { ...REVIEW_RULE }]);
|
|
177
|
+
assert.equal(errs.length, 1);
|
|
178
|
+
assert.match(errs[0], /duplicate key/);
|
|
179
|
+
|
|
180
|
+
// A rule missing any required field is named, field by field.
|
|
181
|
+
const shape = cascade.assertRuleShape({ key: 'x' });
|
|
182
|
+
assert.ok(shape.some((p) => /discipline/.test(p)));
|
|
183
|
+
assert.ok(shape.some((p) => /when/.test(p)));
|
|
184
|
+
assert.ok(shape.some((p) => /title/.test(p)));
|
|
185
|
+
assert.ok(shape.some((p) => /body/.test(p)));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await test('the production table ships EMPTY, deliberately — R04 writes the first rule', () => {
|
|
189
|
+
// If this fails because a rule was added, that is fine: replace the assertion
|
|
190
|
+
// with one that pins the new rule's key and discipline. It exists so the
|
|
191
|
+
// emptiness is a DECISION on the record (see cascade.js's header) rather than
|
|
192
|
+
// something nobody noticed.
|
|
193
|
+
assert.equal(cascade.CASCADES.length, 0);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
197
|
+
process.exit(failed ? 1 : 0);
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.527",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.527",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.527",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -48,9 +48,14 @@ function scriptToVerb(verbs = VERBS) {
|
|
|
48
48
|
// skills + settings.json hooks use. The optional $CLAUDE_PROJECT_DIR/ or ./ prefix and surrounding
|
|
49
49
|
// quote are consumed so the whole thing collapses to the bongos form. `.claude/hooks/<x>.js` refs are
|
|
50
50
|
// deliberately NOT matched (this targets scripts/gds/) — those stay instance-local after materialize.
|
|
51
|
-
|
|
51
|
+
// The launcher is bare `node`, OR the hook resolver `sh "$CLAUDE_PROJECT_DIR/.claude/hooks/run-node.sh"`
|
|
52
|
+
// that every settings.json hook command goes through since task 1003548 (it finds node when the hook
|
|
53
|
+
// shell's PATH lacks it). Both collapse to the same bongos form; a resolver call whose target is a
|
|
54
|
+
// .claude/hooks/*.js stays untouched exactly like the bare-node form did.
|
|
55
|
+
const LAUNCHER = String.raw`(?:node|sh\s+"?(?:\$CLAUDE_PROJECT_DIR\/|\.\/)?\.claude\/hooks\/run-node\.sh"?)`;
|
|
56
|
+
const GDS_INVOCATION_RE = new RegExp(LAUNCHER + String.raw`\s+"?(?:\$CLAUDE_PROJECT_DIR\/|\.\/)?scripts\/gds\/([a-zA-Z0-9_-]+)\.js"?`, 'g');
|
|
52
57
|
// A non-gds core script under scripts/ (rare in skills, but keep the transform total).
|
|
53
|
-
const OTHER_SCRIPT_RE =
|
|
58
|
+
const OTHER_SCRIPT_RE = new RegExp(LAUNCHER + String.raw`\s+"?(?:\$CLAUDE_PROJECT_DIR\/|\.\/)?scripts\/((?!gds\/)[a-zA-Z0-9_./-]+\.js)"?`, 'g');
|
|
54
59
|
|
|
55
60
|
// Pure: rewrite core-script invocations in a skill/settings command string to the bongos bin.
|
|
56
61
|
// Idempotent (a string with no matching invocation is returned unchanged).
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -182,7 +182,7 @@ function checkGeneratedArtifactsFresh() {
|
|
|
182
182
|
try {
|
|
183
183
|
execFileSync(process.execPath, [path.join(__dirname, 'gen-repo-map.js'), '--check-block'], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
184
184
|
} catch {
|
|
185
|
-
violations.push('The tracked nested-doc symbol blocks (src/bongos/CLAUDE.md, src/bongos/routes/CLAUDE.md
|
|
185
|
+
violations.push('The tracked nested-doc symbol blocks (src/bongos/CLAUDE.md, src/bongos/routes/CLAUDE.md) are STALE — run `node scripts/gds/gen-repo-map.js` and commit (ADR 0063 / ADR 0110). NB docs/repo-map.md itself is gitignored + deploy-regenerated, not gated here.');
|
|
186
186
|
}
|
|
187
187
|
try {
|
|
188
188
|
execFileSync(process.execPath, [path.join(__dirname, 'gen-session-index.js'), '--check-block'], { cwd: REPO_ROOT, stdio: 'pipe' });
|
|
@@ -1295,9 +1295,48 @@ function checkErrorEnvelope() {
|
|
|
1295
1295
|
};
|
|
1296
1296
|
}
|
|
1297
1297
|
|
|
1298
|
+
// ============================================================================
|
|
1299
|
+
// Check 2c — every SKILL.md frontmatter parses the way Claude Code's YAML reader reads it (task 1003548)
|
|
1300
|
+
// ============================================================================
|
|
1301
|
+
// Ten of fifty skills shipped with an unquoted description holding ": " or " #": YAML dropped or
|
|
1302
|
+
// truncated EVERY field, the skill matched on its body text, and nothing in CI noticed. A parse
|
|
1303
|
+
// error is a hard fail; description length and the listing budget are advisory (the listing is
|
|
1304
|
+
// resident in every session, ~1% of context, and gets truncated past ~8k chars). The check reads
|
|
1305
|
+
// the instance's .claude/skills/ and is a quiet pass when a checkout carries none (published core).
|
|
1306
|
+
function checkSkillFrontmatter() {
|
|
1307
|
+
const violations = [];
|
|
1308
|
+
const warnings = [];
|
|
1309
|
+
let lint;
|
|
1310
|
+
try { lint = require('./skill-lint'); } catch (e) {
|
|
1311
|
+
return { name: 'skill frontmatter parses (task 1003548)', ok: true, hardFail: false, violations, warnings: [`skill-lint unavailable: ${e.message}`], note: 'linter missing — check skipped.' };
|
|
1312
|
+
}
|
|
1313
|
+
const files = lint.listSkillFiles(INSTANCE_ROOT);
|
|
1314
|
+
const report = lint.lintSkills(files);
|
|
1315
|
+
const rel = (f) => path.relative(INSTANCE_ROOT, f).replace(/\\/g, '/');
|
|
1316
|
+
const long = [];
|
|
1317
|
+
for (const r of report.results) {
|
|
1318
|
+
for (const e of r.errors) violations.push(`${rel(r.file)}: ${e}`);
|
|
1319
|
+
if (r.description.length > lint.DESC_WARN_CHARS) long.push({ file: rel(r.file), n: r.description.length });
|
|
1320
|
+
}
|
|
1321
|
+
if (long.length) {
|
|
1322
|
+
long.sort((a, b) => b.n - a.n);
|
|
1323
|
+
warnings.push(`${long.length} description(s) over ${lint.DESC_WARN_CHARS} chars (longest: ${long.slice(0, 4).map((l) => `${l.file.split('/')[2]} ${l.n}`).join(', ')}) — every char is resident in every session; run node scripts/gds/skill-lint.js.`);
|
|
1324
|
+
}
|
|
1325
|
+
if (report.overBudget) warnings.push(`the skill listing is ${report.listingChars} chars (~${estTokens(report.listingChars)} tokens) against a ~${report.budgetChars}-char budget — Claude Code truncates entries past it and routing degrades.`);
|
|
1326
|
+
return {
|
|
1327
|
+
name: 'skill frontmatter parses (task 1003548)',
|
|
1328
|
+
ok: violations.length === 0,
|
|
1329
|
+
hardFail: violations.length > 0,
|
|
1330
|
+
violations,
|
|
1331
|
+
warnings,
|
|
1332
|
+
note: `${report.results.length} SKILL.md file(s) under .claude/skills/ parse as YAML (name + description present, no ": " / " #" in a plain scalar); length + listing budget advisory.`,
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1298
1336
|
const CHECKS = [
|
|
1299
1337
|
checkCoreHostBoundary,
|
|
1300
1338
|
checkClaudeMdBudget,
|
|
1339
|
+
checkSkillFrontmatter,
|
|
1301
1340
|
checkNestedDocsPresent,
|
|
1302
1341
|
checkRankGuards,
|
|
1303
1342
|
checkGeneratedArtifactsFresh,
|
|
@@ -61,7 +61,9 @@ const SCAN_ROOTS = [...CORE_SCAN_ROOTS, ...INSTANCE_SCAN_ROOTS, MODULE_SCAN_ROOT
|
|
|
61
61
|
|
|
62
62
|
// Directories whose nested CLAUDE.md gets a generated symbol-skeleton block (must already exist —
|
|
63
63
|
// they are authored in Path A / ADR 0061; this only refreshes the factual half). All CORE dirs.
|
|
64
|
-
|
|
64
|
+
// scripts/gds dropped (task 1003548): its block had grown to ~40k chars — 76% of a file every builder
|
|
65
|
+
// session loads when it touches claim/ship. docs/repo-map.md still carries the full index.
|
|
66
|
+
const NESTED_DIRS = ['src/bongos', 'src/bongos/routes'];
|
|
65
67
|
|
|
66
68
|
const EXCLUDE_DIR = new Set(['node_modules', '.git', 'tests', 'test', 'vendor']);
|
|
67
69
|
const TOP_GLOBAL = 50; // global "most-referenced" table cap
|
|
@@ -331,13 +331,13 @@ async function main() {
|
|
|
331
331
|
if (!transcriptPath) transcriptPath = newestTranscriptFor(cwd);
|
|
332
332
|
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
333
333
|
// nothing to price — quiet skip
|
|
334
|
-
|
|
334
|
+
return;
|
|
335
335
|
}
|
|
336
336
|
if (!sessionId) sessionId = path.basename(transcriptPath, '.jsonl');
|
|
337
337
|
|
|
338
338
|
// No GDS session → nothing to attribute spend to; skip quietly.
|
|
339
339
|
const session = await loadSession();
|
|
340
|
-
if (!session || !session.token)
|
|
340
|
+
if (!session || !session.token) return;
|
|
341
341
|
|
|
342
342
|
const inter = priceTranscript(transcriptPath);
|
|
343
343
|
|
|
@@ -347,7 +347,7 @@ async function main() {
|
|
|
347
347
|
const sub = priceSubagents(sessionDir);
|
|
348
348
|
|
|
349
349
|
const total = inter.total + sub.subagentTotal;
|
|
350
|
-
if (!(total > 0))
|
|
350
|
+
if (!(total > 0)) return;
|
|
351
351
|
|
|
352
352
|
// task 1344: split into call-site-tagged, tier-carrying rows; cron sessions
|
|
353
353
|
// are tagged via OTB_COST_CRON_ROUTINE.
|
|
@@ -362,7 +362,7 @@ async function main() {
|
|
|
362
362
|
console.log(` ${r.source} (${r.source_ref}) $${r.amount_usd.toFixed(4)} tiers: fresh $${r.tiers.fresh_input_usd} / cache_read $${r.tiers.cache_read_usd} / cache_write $${r.tiers.cache_write_usd} / output $${r.tiers.output_usd}`);
|
|
363
363
|
}
|
|
364
364
|
console.log(` reconcile: prior-single $${built.priorSingle.toFixed(4)} vs rows-sum $${built.rowsTotal.toFixed(4)} (Δ $${Math.abs(built.priorSingle - built.rowsTotal).toFixed(4)})`);
|
|
365
|
-
|
|
365
|
+
return;
|
|
366
366
|
}
|
|
367
367
|
|
|
368
368
|
const api = await cliClient({ allowUnauthenticated: true });
|
|
@@ -389,18 +389,21 @@ async function main() {
|
|
|
389
389
|
console.error(`session-cost: skipped (non-fatal): HTTP ${res.status} for ${r.source}`);
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
|
-
|
|
392
|
+
return;
|
|
393
393
|
} catch (err) {
|
|
394
394
|
// Best-effort: never break session exit.
|
|
395
395
|
console.error('session-cost: skipped (non-fatal):', err && err.message ? err.message : err);
|
|
396
|
-
|
|
396
|
+
return;
|
|
397
397
|
}
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
// Run only when invoked directly (the SessionEnd hook / CLI). When required by a
|
|
401
401
|
// test, expose the pure-ish helpers without firing the IIFE / network / exit.
|
|
402
402
|
if (require.main === module) {
|
|
403
|
-
|
|
403
|
+
// Drain instead of process.exit(): an exit while a socket/child handle is still closing aborts node on
|
|
404
|
+
// Windows (UV_HANDLE_CLOSING, src\win\async.c) — 59 SessionStart/UserPromptSubmit runs died that way
|
|
405
|
+
// (task 1003548). The unref'd backstop only fires if a hung request keeps the loop alive.
|
|
406
|
+
main().catch(() => {}).finally(() => { process.exitCode = 0; setTimeout(() => process.exit(0), 1500).unref(); });
|
|
404
407
|
}
|
|
405
408
|
|
|
406
409
|
module.exports = {
|
|
@@ -127,12 +127,12 @@ async function main() {
|
|
|
127
127
|
});
|
|
128
128
|
|
|
129
129
|
if (!transcriptPath) transcriptPath = newestTranscriptFor(cwd || process.cwd());
|
|
130
|
-
if (!transcriptPath || !fs.existsSync(transcriptPath))
|
|
130
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return; // nothing to capture
|
|
131
131
|
if (!sessionId) sessionId = path.basename(transcriptPath, '.jsonl');
|
|
132
132
|
|
|
133
133
|
// No GDS session → nothing to attribute the digest to; skip quietly.
|
|
134
134
|
const session = await loadSession();
|
|
135
|
-
if (!session || !session.token)
|
|
135
|
+
if (!session || !session.token) return;
|
|
136
136
|
|
|
137
137
|
// Secret hygiene: buildSessionDigest (ship.js) is the PRIMARY scrubber — it
|
|
138
138
|
// redacts known secret PATTERNS + a high-entropy catch-all (task 1003).
|
|
@@ -180,10 +180,13 @@ async function main() {
|
|
|
180
180
|
} catch (e) {
|
|
181
181
|
console.error('session-digest: skipped —', e && e.message ? e.message : e);
|
|
182
182
|
} finally {
|
|
183
|
-
|
|
183
|
+
return;
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
-
|
|
187
|
+
// Drain instead of process.exit(): an exit while a socket/child handle is still closing aborts node on
|
|
188
|
+
// Windows (UV_HANDLE_CLOSING, src\win\async.c) — 59 SessionStart/UserPromptSubmit runs died that way
|
|
189
|
+
// (task 1003548). The unref'd backstop only fires if a hung request keeps the loop alive.
|
|
190
|
+
if (require.main === module) main().catch(() => {}).finally(() => { process.exitCode = 0; setTimeout(() => process.exit(0), 1500).unref(); });
|
|
188
191
|
|
|
189
192
|
module.exports = { parseHookInput };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/ship-check.js — run every freshness/fitness check ship.js will run, in one command,
|
|
3
|
+
// BEFORE you ship, and print the healing command for each stale thing.
|
|
4
|
+
//
|
|
5
|
+
// Why (task 1003548): a scan of 140 sessions found builders running these generators and checks by
|
|
6
|
+
// hand 223 times in three days (fitness.js ×84, gen-repo-map ×41, copy-inventory ×42, gen-api-docs
|
|
7
|
+
// ×16, linkify-refs ×13, docs-entropy ×12, gen-file-map ×9, gen-session-index ×6), one Bash call at
|
|
8
|
+
// a time, because a ship that fails preflight costs a grader run. The last 30 days of session logs
|
|
9
|
+
// name a stale generated file in roughly a quarter of all sessions. This is that sequence as one
|
|
10
|
+
// call with one table. It CHANGES NOTHING: every step is a --check; the "fix" column is what to run.
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// node scripts/gds/ship-check.js checks only (fast; no tests)
|
|
14
|
+
// node scripts/gds/ship-check.js --tests also run the DB-free unit lane (npm test) and list failures
|
|
15
|
+
// node scripts/gds/ship-check.js --json
|
|
16
|
+
// Exit 1 when any check is stale/failing, 0 when everything is fresh.
|
|
17
|
+
'use strict';
|
|
18
|
+
const { spawnSync } = require('node:child_process');
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
23
|
+
|
|
24
|
+
// Each step: a label, the argv to run (node scripts run with THIS node), the command that heals it,
|
|
25
|
+
// and how to read a non-zero exit. `optional` steps are skipped when their script is absent.
|
|
26
|
+
const STEPS = [
|
|
27
|
+
{ key: 'fitness', label: 'fitness.js (architecture + budgets + freshness gate)', argv: ['scripts/gds/fitness.js'], fix: 'read the ✗ lines above; each names its fix' },
|
|
28
|
+
{ key: 'repo-map', label: 'nested CLAUDE.md symbol blocks fresh', argv: ['scripts/gds/gen-repo-map.js', '--check-block'], fix: 'node scripts/gds/gen-repo-map.js' },
|
|
29
|
+
{ key: 'session-index', label: 'CLAUDE.md §13 session snippet fresh', argv: ['scripts/gds/gen-session-index.js', '--check-block'], fix: 'node scripts/gds/gen-session-index.js' },
|
|
30
|
+
{ key: 'file-map', label: 'docs/file-map.md skills/routines sections fresh', argv: ['scripts/gds/gen-file-map.js', '--check'], fix: 'node scripts/gds/gen-file-map.js (+ a note in docs/file-map.notes.json for a new skill)' },
|
|
31
|
+
{ key: 'api-docs', label: 'docs/api/openapi.json + api-reference fresh', argv: ['scripts/gds/gen-api-docs.js', '--check'], fix: 'node scripts/gds/gen-api-docs.js' },
|
|
32
|
+
{ key: 'copy-registry', label: 'docs/copy-registry.json fresh', argv: ['scripts/gds/copy-inventory.js', '--check'], fix: 'node scripts/gds/copy-inventory.js', optional: true },
|
|
33
|
+
{ key: 'linkify', label: 'no bare #NNN task refs in committed markdown', argv: ['scripts/gds/linkify-refs.js', '--check'], fix: 'node scripts/gds/linkify-refs.js', optional: true },
|
|
34
|
+
{ key: 'skill-lint', label: 'SKILL.md frontmatter parses', argv: ['scripts/gds/skill-lint.js'], fix: 'fix the ✗ lines (description: >- block scalars)' },
|
|
35
|
+
{ key: 'tests', label: 'DB-free unit lane (npm test)', argv: ['scripts/gds/run-unit-tests.js'], fix: 'read the FAIL lines; compare against origin/main before calling any of them environmental', onlyWith: '--tests', slow: true },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function runStep(step, { cwd = REPO_ROOT } = {}) {
|
|
39
|
+
const script = path.join(cwd, step.argv[0]);
|
|
40
|
+
if (!fs.existsSync(script)) return { ...step, status: step.optional ? 'skipped' : 'missing', ms: 0, tail: `${step.argv[0]} not present` };
|
|
41
|
+
const t0 = Date.now();
|
|
42
|
+
const r = spawnSync(process.execPath, [script, ...step.argv.slice(1)], { cwd, encoding: 'utf8', timeout: step.slow ? 15 * 60 * 1000 : 5 * 60 * 1000, env: { ...process.env, GDS_QUIET: '1' } });
|
|
43
|
+
const out = ((r.stdout || '') + (r.stderr || '')).trim().split(/\r?\n/);
|
|
44
|
+
const failing = out.filter((l) => /^\s*(✗|FAIL|not ok|STALE|✘)/.test(l) || /STALE|is stale|would update|failed/i.test(l));
|
|
45
|
+
return { ...step, status: r.status === 0 ? 'ok' : 'fail', code: r.status, signal: r.signal, ms: Date.now() - t0, tail: (failing.length ? failing : out.slice(-4)).slice(0, 12).join('\n') };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function summarize(results) {
|
|
49
|
+
const stale = results.filter((r) => r.status === 'fail' || r.status === 'missing');
|
|
50
|
+
return { ok: stale.length === 0, stale: stale.map((r) => r.key), results };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function format(summary) {
|
|
54
|
+
const lines = ['ship-check — what ship.js will trip on', ''];
|
|
55
|
+
for (const r of summary.results) {
|
|
56
|
+
const mark = r.status === 'ok' ? '✓' : r.status === 'skipped' ? '·' : '✗';
|
|
57
|
+
lines.push(`${mark} ${r.label} (${r.ms} ms)`);
|
|
58
|
+
if (r.status !== 'ok' && r.status !== 'skipped') {
|
|
59
|
+
for (const t of String(r.tail).split('\n')) lines.push(` ${t}`);
|
|
60
|
+
lines.push(` fix → ${r.fix}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push(summary.ok ? 'all fresh — ship.js preflight should pass; the grader and CI are the remaining gates.' : `${summary.stale.length} stale: ${summary.stale.join(', ')} — heal, commit, re-run.`);
|
|
65
|
+
return lines.join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function main() {
|
|
69
|
+
const argv = process.argv.slice(2);
|
|
70
|
+
const steps = STEPS.filter((s) => !s.onlyWith || argv.includes(s.onlyWith));
|
|
71
|
+
const results = steps.map((s) => runStep(s));
|
|
72
|
+
const summary = summarize(results);
|
|
73
|
+
console.log(argv.includes('--json') ? JSON.stringify(summary, null, 1) : format(summary));
|
|
74
|
+
process.exitCode = summary.ok ? 0 : 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = { STEPS, runStep, summarize, format };
|
|
78
|
+
if (require.main === module) main();
|
|
@@ -87,12 +87,12 @@ function regenerateRepoMap() {
|
|
|
87
87
|
console.log(' repo-map regen failed (non-fatal) — CI --check-block backstops the tracked blocks');
|
|
88
88
|
return 'regen-failed';
|
|
89
89
|
}
|
|
90
|
-
const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md
|
|
90
|
+
const dirty = gitOk('git status --porcelain src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md');
|
|
91
91
|
if (!dirty) {
|
|
92
92
|
console.log(' nested-doc symbol blocks already current — nothing to commit (docs/repo-map.md is gitignored, ADR 0110)');
|
|
93
93
|
return 'current';
|
|
94
94
|
}
|
|
95
|
-
shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md
|
|
95
|
+
shellOk('git add src/bongos/CLAUDE.md src/bongos/routes/CLAUDE.md');
|
|
96
96
|
if (shellOk('git commit -m "chore(repo-map): auto-regenerate nested-doc symbol blocks (task 1224)"')) {
|
|
97
97
|
console.log(' committed auto-regenerated nested-doc symbol blocks');
|
|
98
98
|
return 'committed';
|