@bongos/core 1.19.793 → 1.19.795

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.793",
6
- "core_contract": "1.19.793",
7
- "source_commit": "877a1e0befbf245c4ac50b28d0bc72c785dc9d27",
5
+ "core_version": "1.19.795",
6
+ "core_contract": "1.19.795",
7
+ "source_commit": "59f35c05e7edb8428f4c532e5032e9333bd7a0b9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T00:41:15.216Z",
9
+ "built_at": "2026-09-17T00:54:38.883Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 486,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2179,
14
+ "functional_verbatim": 2182,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2690,
20
- "tree_sha256": "17b680447915370ad9005373e97e6a90d25f03ebdf58f52e2902501c26fe403a",
19
+ "file_count": 2693,
20
+ "tree_sha256": "bb912c280be9ea67a9825f8ee7e6bbc68b2be165c826b3fb40185a0badd7cd9d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2527,7 +2527,7 @@
2527
2527
  {
2528
2528
  "path": "docs/module-api-changelog.md",
2529
2529
  "mode": "0000644",
2530
- "sha256": "5a46ed6753d0b49d3a65f6f58972a5159a115ac6fa0022d628942cb48b80be95"
2530
+ "sha256": "d9df5939232118367f1db9c0127c514a19f625aa46a8375e65dda21705e84671"
2531
2531
  },
2532
2532
  {
2533
2533
  "path": "docs/modules-contract.md",
@@ -3614,6 +3614,11 @@
3614
3614
  "mode": "0000644",
3615
3615
  "sha256": "8e3e2db2ab3b7775bddf12f3a7ec3ed74477334b6388487ecd919cd61a62b0ba"
3616
3616
  },
3617
+ {
3618
+ "path": "migrations/core_244_skill_friction_kind.sql",
3619
+ "mode": "0000644",
3620
+ "sha256": "e298a5b2e9ffade95fae0e68910a25396fab560e6e54dd745cfc122c0dfdf403"
3621
+ },
3617
3622
  {
3618
3623
  "path": "modules/agents/CLAUDE.md",
3619
3624
  "mode": "0000644",
@@ -5377,7 +5382,7 @@
5377
5382
  {
5378
5383
  "path": "modules/ideas/inbox.js",
5379
5384
  "mode": "0000644",
5380
- "sha256": "8c0792629df6d9587bce828a0f3743b8c86bdb31ba226d31211b9c6623574395"
5385
+ "sha256": "6c619200312b32cbc268c44b41c4f3f574f7917a31187b69cfeba105c804ddee"
5381
5386
  },
5382
5387
  {
5383
5388
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -5429,6 +5434,11 @@
5429
5434
  "mode": "0000644",
5430
5435
  "sha256": "da653f475d6c91db8e84bff46c797bb68b61e48d561eacf7bfd228a119ac7266"
5431
5436
  },
5437
+ {
5438
+ "path": "modules/ideas/skill-friction.js",
5439
+ "mode": "0000644",
5440
+ "sha256": "eb6a5cecb3ce54aeb0c85ffb752eb12a2400d0d4f5c7d48e4e05ff3cf7baf712"
5441
+ },
5432
5442
  {
5433
5443
  "path": "modules/ideas/templates.js",
5434
5444
  "mode": "0000644",
@@ -7692,12 +7702,12 @@
7692
7702
  {
7693
7703
  "path": "package-lock.json",
7694
7704
  "mode": "0000644",
7695
- "sha256": "88ee2ff930be2d84d19733724ddfeee1842cba1b890422713c9012466fa0739a"
7705
+ "sha256": "abe39cacffdf6a8359f19b7e7a5a06a7055f99637edc556db2124968bdae5eb8"
7696
7706
  },
7697
7707
  {
7698
7708
  "path": "package.json",
7699
7709
  "mode": "0000644",
7700
- "sha256": "f7f66eb317b78a0b652addc0c31a67500a80ed2109807a89898128d6a15a481b"
7710
+ "sha256": "6f9922b6e8c2eb446a10daa28bd6a34da2097b36098a70db45f16010c8529217"
7701
7711
  },
7702
7712
  {
7703
7713
  "path": "public-docs/index.html",
@@ -7987,7 +7997,7 @@
7987
7997
  {
7988
7998
  "path": "scripts/gds/claude-materialize.js",
7989
7999
  "mode": "0000644",
7990
- "sha256": "9ab25dfe2ce4e2910ab9b5558105a6ee24f095c4e03c1e6a92b2d1a2e94ce703"
8000
+ "sha256": "8cf425aed2764fc043c8a8757f178f7514a907567d559e7f0b11e091f33b66e5"
7991
8001
  },
7992
8002
  {
7993
8003
  "path": "scripts/gds/claude-md-template.js",
@@ -8932,7 +8942,7 @@
8932
8942
  {
8933
8943
  "path": "scripts/gds/ship-flow.js",
8934
8944
  "mode": "0000644",
8935
- "sha256": "56857161a882d20a4d45a04f20bd53bce0e6d946002a7cd3d4548cbed7f2855c"
8945
+ "sha256": "fe64da47ac66f589d0245e02b321dbd708e695dd3875e75e8b4ac36e6aa31636"
8936
8946
  },
8937
8947
  {
8938
8948
  "path": "scripts/gds/ship-git.js",
@@ -8952,7 +8962,7 @@
8952
8962
  {
8953
8963
  "path": "scripts/gds/ship-honesty.js",
8954
8964
  "mode": "0000644",
8955
- "sha256": "29116265032b48115c31df794aa4689e7ce3f5eaeef152ea1ca2390dc2078c39"
8965
+ "sha256": "3ab4816fdd43d589cda73698a514f8e23103be166c1650fd24beeee9f529bb5b"
8956
8966
  },
8957
8967
  {
8958
8968
  "path": "scripts/gds/ship-io.js",
@@ -9577,7 +9587,7 @@
9577
9587
  {
9578
9588
  "path": "src/module-api.js",
9579
9589
  "mode": "0000644",
9580
- "sha256": "7db202b3ac70a0404e7cc0067828bb9d41eed6bfddf760e325773f629cd09dfc"
9590
+ "sha256": "e21607486d5b15d09ea4e0f1ad1a3a626c9dbaf3713aa601dd113e5e3d7bd13e"
9581
9591
  },
9582
9592
  {
9583
9593
  "path": "src/module-loader/catalog.js",
@@ -10247,7 +10257,7 @@
10247
10257
  {
10248
10258
  "path": "tests/claude_materialize.mjs",
10249
10259
  "mode": "0000644",
10250
- "sha256": "8292d8c7a788e7839599d38f6c78a5dbb68f6ae50a58771a66e6481a562f0ace"
10260
+ "sha256": "7acfe432856010881b234b258d45fcf65f976e0e711d01b823d90990a0c7179c"
10251
10261
  },
10252
10262
  {
10253
10263
  "path": "tests/clawback_confirmed_backlog.mjs",
@@ -12892,7 +12902,7 @@
12892
12902
  {
12893
12903
  "path": "tests/ship_cannot_lie.mjs",
12894
12904
  "mode": "0000644",
12895
- "sha256": "18a3bb613fff3dcc0dfbffb2283a866726af4347c161fc28853b36b5bdd75512"
12905
+ "sha256": "55e94ed59bc1bd19e4a3dd0e9a9a415dde659c5d71af0dd827d1b288e66de3c7"
12896
12906
  },
12897
12907
  {
12898
12908
  "path": "tests/ship_card.mjs",
@@ -12984,6 +12994,11 @@
12984
12994
  "mode": "0000644",
12985
12995
  "sha256": "0eeccae9320f419667cf9ede3c13671a5b03571f7f2f17067bff1857234ddf91"
12986
12996
  },
12997
+ {
12998
+ "path": "tests/skill_friction_kind.mjs",
12999
+ "mode": "0000644",
13000
+ "sha256": "baaf5935cc748e7eb9596d1c96c3c20aa2ee3917377d32b9104408b7b07165a4"
13001
+ },
12987
13002
  {
12988
13003
  "path": "tests/skill_grade_audit.mjs",
12989
13004
  "mode": "0000644",
@@ -2060,5 +2060,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2060
2060
  landed since 1.19.791 with no explicit bump. run 35166486577. (task 1002620)
2061
2061
  1.19.793 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2062
2062
  landed since 1.19.792 with no explicit bump. run 35167484049. (task 1002620)
2063
+ 1.19.794 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2064
+ landed since 1.19.793 with no explicit bump. run 35167814500. (task 1002620)
2065
+ 1.19.795 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2066
+ landed since 1.19.794 with no explicit bump. run 35168408728. (task 1002620)
2063
2067
  ---------------------------------------------------------------------------
2064
2068
  ```
@@ -0,0 +1,74 @@
1
+ -- core_244_skill_friction_kind.sql — 'skill-friction' becomes a real kind
2
+ -- (task 1003105, goal 1000095, promoted from idea 1000759).
3
+ --
4
+ -- WHY. Two skills instruct the agent to file a retrospective finding as
5
+ -- `kind='skill-friction'` — `.claude/skills/planning-session/SKILL.md` ("After
6
+ -- the session — improvements pass") and `.claude/skills/priority-session/
7
+ -- SKILL.md`. Neither works: the CHECK constraint on `idea_inbox.kind` (migration
8
+ -- 006, extended by 042) admits only the fixed task-kind enum, so `POST /inbox`
9
+ -- fails with BAD_KIND. Hit live on 2026-08-16 — all three retrospective ideas
10
+ -- from the Board Room planning session failed on the first attempt and had to be
11
+ -- re-homed under unrelated kinds. The cost is not the error message: this is the
12
+ -- step that makes the planning skill self-improving, and because it fails closed
13
+ -- the findings are either dropped or filed somewhere nobody looks for them.
14
+ --
15
+ -- WHY NOW, AND NOT AS A TITLE PREFIX. The nightly `skill-usage-review` routine
16
+ -- already files these rows, and it works — by tagging a `[skill-friction]`
17
+ -- title prefix over a valid enum `kind`. That workaround names its own exit
18
+ -- condition, in `modules/ideas/inbox.js` and in the routine's SKILL.md: "when
19
+ -- the rank system lands a real builders.rank column + idea_inbox.kind
20
+ -- migration, this prefix convention can be promoted to a first-class kind."
21
+ -- The rank system HAS landed — `builders.rank` is live and enforced server-side
22
+ -- (ADR 0016), and `viewerRank` is threaded into all four inbox read paths from
23
+ -- `req.builder.rank`. So the condition the workaround was waiting on is met,
24
+ -- and this is the migration it asked for.
25
+ --
26
+ -- WHAT THIS DOES NOT DO. It does not backfill the existing `[skill-friction]`-
27
+ -- prefixed rows, and it does not change what the nightly routine writes. Both
28
+ -- are deliberate: the deployed core pin runs behind `main` by design (gate 3 of
29
+ -- docs/recipes/core-release-pipeline.md), so between this landing and the pin
30
+ -- moving there is a window in which the DB accepts the new kind and the running
31
+ -- instance's VALID_KINDS does not offer it. A filer flipped to the new kind in
32
+ -- that window would start failing where it currently succeeds — strictly worse
33
+ -- than the bug being fixed. So the marker stays DUAL: `inbox.js` recognises a
34
+ -- row as skill-friction by its kind OR its legacy title prefix, permanently and
35
+ -- by design, and flipping the routine is a follow-up for after the pin moves.
36
+ --
37
+ -- WHY CORE AND NOT modules/ideas/migrations/. `idea_inbox` is a CORE table: it
38
+ -- was created by migration 006 and every later change to it — 042 (the last
39
+ -- widening of this same constraint), core_195, core_222, core_228 — is a core
40
+ -- migration. The `ideas` module owns the `ideas_*` tables, not this one, and a
41
+ -- module migration may only touch its own namespace and must be additive.
42
+ -- Widening a CHECK requires dropping it, so this could not have been a module
43
+ -- migration even if the table had been the module's. `fitness.js` enforces both
44
+ -- rules and caught the first attempt at this file in the wrong place.
45
+ --
46
+ -- The constraint is dropped by LOOKUP rather than by name, exactly as 042 does
47
+ -- it, and for 042's reason: a database that somehow never ran 042 still carries
48
+ -- the 006-era constraint under its original name, and a `DROP CONSTRAINT IF
49
+ -- EXISTS idea_inbox_kind_check` there would silently no-op and leave the old,
50
+ -- narrower constraint in force — which rejects the very kind this adds.
51
+ --
52
+ -- REVERSIBILITY. Dropping back is the same DO block with 'skill-friction'
53
+ -- removed from the list, and is safe only while no row carries the kind.
54
+
55
+ DO $$
56
+ DECLARE cname text;
57
+ BEGIN
58
+ SELECT conname INTO cname
59
+ FROM pg_constraint
60
+ WHERE conrelid = 'idea_inbox'::regclass
61
+ AND contype = 'c'
62
+ AND pg_get_constraintdef(oid) ILIKE '%kind%IN%';
63
+ IF cname IS NOT NULL THEN
64
+ EXECUTE format('ALTER TABLE idea_inbox DROP CONSTRAINT %I', cname);
65
+ END IF;
66
+ END $$;
67
+
68
+ ALTER TABLE idea_inbox
69
+ ADD CONSTRAINT idea_inbox_kind_check
70
+ CHECK (kind IN (
71
+ 'feature','bug','infra','refactor','spike',
72
+ 'learning-capture','cleanup','decision','blocker-resolution',
73
+ 'unclassified','criterion-proposal','skill-friction'
74
+ ));
@@ -42,6 +42,12 @@ const VALID_KINDS = [
42
42
  // ratify endpoint (assert kind='criterion-proposal' before promotion).
43
43
  // CHECK constraint updated in migration 042.
44
44
  'criterion-proposal',
45
+ // task 1003105 — `/planning-session` and `/priority-session` both instruct the
46
+ // agent to file a retrospective finding as kind='skill-friction', and both
47
+ // failed BAD_KIND until now. The DB CHECK constraint is widened to match in
48
+ // migrations/core_244_skill_friction_kind.sql, which also
49
+ // explains why the legacy title-prefix marker below stays alongside it.
50
+ 'skill-friction',
45
51
  ];
46
52
  const VALID_STATUSES = ['open', 'promoted', 'discarded', 'merged'];
47
53
 
@@ -121,45 +127,13 @@ function ungradedProjection() {
121
127
  }
122
128
 
123
129
  // -------------------------------------------------------------------------
124
- // Skill-friction tagging (task V3.R70 / #289)
125
- //
126
- // The nightly `skill-usage-review` routine files one idea_inbox row per
127
- // friction pattern it detects. The done-when calls these "kind='skill-friction'"
128
- // rows, but the DB CHECK constraint on idea_inbox.kind (migration 006) only
129
- // admits the fixed task-kind enum — adding a literal 'skill-friction' kind
130
- // would need a new migration, and migrations are explicitly out of scope for
131
- // this task. So we tag friction rows the same way overnight-code-review tags
132
- // its findings: a stable title prefix. The underlying `kind` stays a valid
133
- // enum value (cleanup), and SKILL_FRICTION_TAG is the machine-checkable marker.
134
- //
135
- // When the GDS-V3 rank system (V3.R04) lands a real builders.rank column +
136
- // idea_inbox.kind migration, this prefix convention can be promoted to a
137
- // first-class kind value and this marker collapses to a kind check.
138
- const SKILL_FRICTION_TAG = '[skill-friction]';
139
-
140
- // -------------------------------------------------------------------------
141
- // Rank visibility (task V3.R70 / #289)
142
- //
143
- // Three ranks are planned in GDS-V3 (V3.R04): Xenos (default for any
144
- // GitHub-authed person), Metic (Archon-approved), Archon. Skill-friction
145
- // ideas are a system-evolution signal meant for trusted builders — Xenos
146
- // must NOT see them in their inbox view; Metic and Archon may.
147
- //
148
- // `builders.rank` does not exist in the schema yet (it lands with R04), and
149
- // requireBuilder does not yet populate req.builder.rank — so today every
150
- // caller passes viewerRank=null. The gate is written to be correct the moment
151
- // rank is threaded through: it hides skill-friction rows ONLY when the viewer
152
- // is positively known to be Xenos. Unknown/absent rank (today's reality, where
153
- // no Xenos exist yet) is treated as Metic+ and sees everything — so wiring this
154
- // in does not regress current all-builders behavior.
155
- const XENOS_RANK = 'xenos';
156
-
157
- function rankCanSeeSkillFriction(viewerRank) {
158
- // Hide only when we KNOW the viewer is Xenos. null/undefined/anything else
159
- // (Metic, Archon, or "rank system not deployed") => allowed.
160
- if (viewerRank == null) return true;
161
- return String(viewerRank).toLowerCase() !== XENOS_RANK;
162
- }
130
+ // Skill-friction: the marker a row carries and the rank gate that hides it.
131
+ // Pure, and its own file (./skill-friction.js) for the reason written there —
132
+ // re-exported below so every existing caller keeps working.
133
+ const {
134
+ SKILL_FRICTION_TAG, SKILL_FRICTION_KIND, XENOS_RANK,
135
+ rankCanSeeSkillFriction, skillFrictionHiddenClause,
136
+ } = require('./skill-friction');
163
137
 
164
138
  // -------------------------------------------------------------------------
165
139
  // Fallback classifier — keyword heuristics over title+body.
@@ -511,9 +485,8 @@ async function routeIdeaToGoal({
511
485
  // see skill-friction rows; Metic+ (or unknown rank, pre-R04) see everything.
512
486
  // We filter in SQL when the viewer is positively Xenos so the LIMIT still
513
487
  // returns up to `limit` *visible* rows rather than `limit` rows that then get
514
- // thinned out in JS. The skill-friction marker is a title prefix (see
515
- // SKILL_FRICTION_TAG above) because the kind enum cannot take a new value
516
- // without a migration.
488
+ // thinned out in JS. The marker is a kind OR a legacy title prefix — see
489
+ // skillFrictionHiddenClause, which is the only place that knows that.
517
490
  async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}) {
518
491
  const params = [];
519
492
  const where = [`i.status = 'open'`];
@@ -521,10 +494,8 @@ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}
521
494
  params.push(kind);
522
495
  where.push(`i.kind = $${params.length}`);
523
496
  }
524
- if (!rankCanSeeSkillFriction(viewerRank)) {
525
- params.push(`${SKILL_FRICTION_TAG}%`);
526
- where.push(`i.title NOT LIKE $${params.length}`);
527
- }
497
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
498
+ if (hideFriction) where.push(hideFriction);
528
499
  params.push(limit);
529
500
  const { rows } = await pool.query(
530
501
  `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
@@ -570,10 +541,8 @@ async function getIdea(id) {
570
541
  async function countOpenIdeas({ viewerRank = null } = {}) {
571
542
  const params = [];
572
543
  const where = [`status = 'open'`];
573
- if (!rankCanSeeSkillFriction(viewerRank)) {
574
- params.push(`${SKILL_FRICTION_TAG}%`);
575
- where.push(`title NOT LIKE $${params.length}`);
576
- }
544
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params);
545
+ if (hideFriction) where.push(hideFriction);
577
546
  const { rows } = await pool.query(
578
547
  `SELECT count(*)::int AS n FROM idea_inbox WHERE ${where.join(' AND ')}`,
579
548
  params
@@ -619,10 +588,8 @@ const IDEA_TRAIL_MAX_LIMIT = 100;
619
588
  function ideaTrailWhere(builderId, viewerRank, params) {
620
589
  params.push(builderId);
621
590
  const where = [`i.captured_by = $${params.length}`];
622
- if (!rankCanSeeSkillFriction(viewerRank)) {
623
- params.push(`${SKILL_FRICTION_TAG}%`);
624
- where.push(`i.title NOT LIKE $${params.length}`);
625
- }
591
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
592
+ if (hideFriction) where.push(hideFriction);
626
593
  return where.join(' AND ');
627
594
  }
628
595
 
@@ -1029,10 +996,8 @@ async function countRoutedAwaitingNod(deps = {}) {
1029
996
  // agreeing by construction beats them agreeing by accident.
1030
997
  function sparkQueueWhere(viewerRank, params) {
1031
998
  const where = [`g.grade = 'spark'`, `i.status = 'open'`];
1032
- if (!rankCanSeeSkillFriction(viewerRank)) {
1033
- params.push(`${SKILL_FRICTION_TAG}%`);
1034
- where.push(`i.title NOT LIKE $${params.length}`);
1035
- }
999
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
1000
+ if (hideFriction) where.push(hideFriction);
1036
1001
  return where;
1037
1002
  }
1038
1003
 
@@ -1455,10 +1420,13 @@ module.exports = {
1455
1420
  DEFAULT_TEMPLATE_KEY,
1456
1421
  TEMPLATE_KEY_MAX,
1457
1422
  FIELD_VALUES_MAX,
1458
- // skill-friction tagging + rank visibility gate (V3.R70 / #289)
1423
+ // skill-friction marker + rank visibility gate (V3.R70 / #289; the kind
1424
+ // itself landed in task 1003105 — both markers are live, see above)
1459
1425
  SKILL_FRICTION_TAG,
1426
+ SKILL_FRICTION_KIND,
1460
1427
  XENOS_RANK,
1461
1428
  rankCanSeeSkillFriction,
1429
+ skillFrictionHiddenClause,
1462
1430
  // capture
1463
1431
  captureIdea,
1464
1432
  // read
@@ -0,0 +1,97 @@
1
+ // modules/ideas/skill-friction.js — the skill-friction marker and the rank
2
+ // gate that hides those rows, extracted from inbox.js (task 1003105).
3
+ //
4
+ // WHY ITS OWN FILE. Two reasons, and the second is the real one.
5
+ //
6
+ // `inbox.js` sits against the repo's per-file size budget, and adding the kind
7
+ // plus the reasoning for keeping the legacy marker pushed it over — the fitness
8
+ // ratchet's whole job. Trimming the explanation to fit would have deleted the
9
+ // part a later reader needs most: WHY there are two markers rather than one.
10
+ //
11
+ // And this was always a separable concern. It is pure — no pool, no I/O, no
12
+ // knowledge of a query beyond emitting a fragment — which is the same test
13
+ // `./routing.js` and `./homeless-home.js` already passed when they were split
14
+ // out of the same file. `inbox.js` re-exports everything here, so nothing that
15
+ // reached these through it has to change.
16
+
17
+ 'use strict';
18
+
19
+ // Skill-friction tagging (task V3.R70 / #289)
20
+ //
21
+ // A skill-friction row carries TWO markers, both first-class, both permanent.
22
+ //
23
+ // The nightly `skill-usage-review` routine files one idea_inbox row per
24
+ // friction pattern it detects, and it has always worked — by tagging a stable
25
+ // title prefix over a valid enum `kind`, because the CHECK constraint on
26
+ // idea_inbox.kind admitted no such value. That workaround named its own exit
27
+ // condition, here and in the routine's SKILL.md: promote the prefix to a real
28
+ // kind once the rank system lands a `builders.rank` column and a migration.
29
+ //
30
+ // Both have landed — rank is live and enforced server-side (ADR 0016), and
31
+ // `viewerRank` reaches all four read paths below from `req.builder.rank` — so
32
+ // task 1003105 added the kind (core_244_skill_friction_kind.sql). What it did
33
+ // NOT do is retire the prefix, and that is the part worth reading twice: the
34
+ // deployed core pin runs behind `main` BY DESIGN (gate 3 of the release
35
+ // pipeline), so there is a window where the database accepts the new kind and
36
+ // the running instance does not yet offer it. A filer flipped inside that
37
+ // window starts failing where it currently succeeds. So the prefix is not a
38
+ // deprecated alias to be swept later — it is the marker every historical row
39
+ // carries and the one the nightly routine still writes, and
40
+ // `skillFrictionHiddenClause` below is the single place that knows both.
41
+ const SKILL_FRICTION_TAG = '[skill-friction]';
42
+ const SKILL_FRICTION_KIND = 'skill-friction';
43
+
44
+ // -------------------------------------------------------------------------
45
+ // Rank visibility (task V3.R70 / #289)
46
+ //
47
+ // Three ranks are planned in GDS-V3 (V3.R04): Xenos (default for any
48
+ // GitHub-authed person), Metic (Archon-approved), Archon. Skill-friction
49
+ // ideas are a system-evolution signal meant for trusted builders — Xenos
50
+ // must NOT see them in their inbox view; Metic and Archon may.
51
+ //
52
+ // `builders.rank` does not exist in the schema yet (it lands with R04), and
53
+ // requireBuilder does not yet populate req.builder.rank — so today every
54
+ // caller passes viewerRank=null. The gate is written to be correct the moment
55
+ // rank is threaded through: it hides skill-friction rows ONLY when the viewer
56
+ // is positively known to be Xenos. Unknown/absent rank (today's reality, where
57
+ // no Xenos exist yet) is treated as Metic+ and sees everything — so wiring this
58
+ // in does not regress current all-builders behavior.
59
+ const XENOS_RANK = 'xenos';
60
+
61
+ function rankCanSeeSkillFriction(viewerRank) {
62
+ // Hide only when we KNOW the viewer is Xenos. null/undefined/anything else
63
+ // (Metic, Archon, or "rank system not deployed") => allowed.
64
+ if (viewerRank == null) return true;
65
+ return String(viewerRank).toLowerCase() !== XENOS_RANK;
66
+ }
67
+
68
+ // skillFrictionHiddenClause — the ONE predicate meaning "this row is not a
69
+ // skill-friction row", for the four read paths that hide them from a Xenos.
70
+ // It tests BOTH markers (see SKILL_FRICTION_TAG above).
71
+ //
72
+ // It exists as a function rather than four copied conditions because the four
73
+ // gates have to agree and nothing made them: a copy that learned about the new
74
+ // kind while its siblings did not would leak skill-friction rows into exactly
75
+ // one Xenos-facing listing, which is the kind of gap nobody notices until it is
76
+ // in front of the wrong reader. Filtering stays in SQL so a LIMIT still returns
77
+ // up to `limit` VISIBLE rows rather than `limit` rows thinned out afterwards.
78
+ //
79
+ // Pushes its own params onto `params`. Returns null when the viewer may see
80
+ // everything — callers push the clause only when it is non-null, so a viewer
81
+ // with no rank restriction costs no bind parameters at all.
82
+ function skillFrictionHiddenClause(viewerRank, params, alias = '') {
83
+ if (rankCanSeeSkillFriction(viewerRank)) return null;
84
+ const col = alias ? `${alias}.` : '';
85
+ params.push(SKILL_FRICTION_KIND);
86
+ const kindParam = params.length;
87
+ params.push(`${SKILL_FRICTION_TAG}%`);
88
+ return `(${col}kind <> $${kindParam} AND ${col}title NOT LIKE $${params.length})`;
89
+ }
90
+
91
+ module.exports = {
92
+ SKILL_FRICTION_TAG,
93
+ SKILL_FRICTION_KIND,
94
+ XENOS_RANK,
95
+ rankCanSeeSkillFriction,
96
+ skillFrictionHiddenClause,
97
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.793",
3
+ "version": "1.19.795",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.793",
9
+ "version": "1.19.795",
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.793",
3
+ "version": "1.19.795",
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",
@@ -16,8 +16,12 @@
16
16
  // package; this module copies+transforms them when they ARE present at the core root (today's single
17
17
  // checkout, and the core repo itself), and no-ops with a note when they aren't (an installed package).
18
18
  // Hooks self-resolve their core require()s at RUNTIME via the copied _core-resolve.js sibling
19
- // (task 2139, the former W3 follow-up), so no require-path rewriting happens here; shipping a
20
- // topology STARTER for a package-only install (hooks absent at the core root) stays a follow-up (task 1883).
19
+ // (task 2139, the former W3 follow-up), so no require-path rewriting happens here. The topology
20
+ // STARTER for a package-only install is now HALF built: settings.json is written from
21
+ // renderStarterSettings() below — permissions only, no hooks — because an instance that received
22
+ // the skills but no permissions met the classifier on every blessed command (task 1003962). A hooks
23
+ // starter remains a follow-up (task 1883), and is a different question: hooks ARE gate machinery,
24
+ // where a list of allowed command names is not.
21
25
  //
22
26
  // SELF-COPY GUARD. When the instance dir IS the core root (running init inside the core checkout /
23
27
  // OTB today), there is nothing to materialize — a self-copy would be a no-op at best and corrupting
@@ -296,6 +300,50 @@ function copyTreeTransformed(srcDir, destDir, { dryRun, onFile, overwrite = true
296
300
 
297
301
  // Transform settings.json: parse, rewrite every hook command through bongosify, re-serialize. Keeps
298
302
  // .claude/hooks/*.js commands (instance-local) untouched and rewrites the scripts/gds/*.js ones.
303
+ // The blessed commands an instance builder runs to do the methodology, one rule
304
+ // each (task 1003962). This MIRRORS the core repo's own `.claude/settings.json`
305
+ // allow-list (ADR 0104 / task 1051), translated from `node scripts/gds/<x>.js`
306
+ // into the verb form a materialized instance actually invokes — bongosify()
307
+ // rewrites every skill to `bongos <verb>`, so a path-shaped rule would match
308
+ // nothing there.
309
+ //
310
+ // THE GAP THIS CLOSES. `.claude/settings.json` is excluded from the published
311
+ // package on purpose (PROTECTED_TOPOLOGY_EXCLUDE, beside infra/ and .github/):
312
+ // the core's own copy is deploy and gate machinery that means nothing on an
313
+ // instance. But the skills ship, so an instance received the methodology and
314
+ // none of the permissions that let it run — every instance builder met the
315
+ // auto-mode classifier on the normal workflow, which is how a live builder came
316
+ // to be stuck mid-re-auth with no way forward.
317
+ //
318
+ // WHY THIS IS NOT AN AUTHORITY GRANT (ADR 0016). An allow-rule stops the local
319
+ // agent REFUSING to run a command; it changes nothing about what the server
320
+ // permits. Rank is read fresh from the database on every request by
321
+ // `requireRank`, so a builder who edits this file grants themselves exactly
322
+ // nothing. That is the distinction that makes shipping a list of command names
323
+ // safe where shipping hooks — actual local gate machinery — is not.
324
+ //
325
+ // DELIBERATELY ABSENT: `bongos exec`. It runs any script inside the core by
326
+ // path and is the agent's escape hatch, so allow-listing it would be a blanket
327
+ // grant wearing a narrow rule's clothes. The one script the core's own list
328
+ // blesses (`api.js`) is named in full instead.
329
+ const STARTER_ALLOW = [
330
+ 'Bash(bongos claim:*)',
331
+ 'Bash(bongos ship:*)',
332
+ 'Bash(bongos release:*)',
333
+ 'Bash(bongos reauth:*)',
334
+ 'Bash(bongos paste-token:*)',
335
+ 'Bash(bongos exec scripts/gds/api.js:*)',
336
+ ];
337
+
338
+ // The starter an instance gets when the core root carries no settings.json — a
339
+ // package-only install. Permissions ONLY: no hooks (they are topology), no env.
340
+ function renderStarterSettings() {
341
+ return `${JSON.stringify({
342
+ _doc: 'Starter written by scripts/gds/claude-materialize.js (task 1003962). Permissions only — the blessed Bongos commands, mirroring the core repo\'s own allow-list (ADR 0104). An allow-rule lets the local agent RUN a command; it grants no authority, which the server decides per request from your live rank (ADR 0016). Yours to edit: a later materialize never clobbers this file.',
343
+ permissions: { allow: STARTER_ALLOW },
344
+ }, null, 2)}\n`;
345
+ }
346
+
299
347
  function transformSettings(srcPath) {
300
348
  const raw = fs.readFileSync(srcPath, 'utf8');
301
349
  let json;
@@ -484,7 +532,25 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
484
532
  log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (hook commands→bongos, identity rebranded)`);
485
533
  }
486
534
  } else {
487
- summary.notes.push('.claude/settings.json not present at the core root (topology, excluded from the package) — provide it from a starter.');
535
+ // Package-only install: the core root carries no settings.json to copy, so
536
+ // write the STARTER instead of leaving the instance with no permissions at
537
+ // all (task 1003962 — the note that used to sit here said "provide it from a
538
+ // starter" and nothing ever did). Written only when ABSENT, the same rule
539
+ // the starter CLAUDE.md below follows: an instance that has grown its own
540
+ // permissions owns them, and a later materialize must never clobber them.
541
+ const settingsDest = path.join(destClaude, 'settings.json');
542
+ if (fs.existsSync(settingsDest)) {
543
+ summary.skippedFiles.push('settings.json');
544
+ log(' • .claude/settings.json already present — left untouched (the instance owns it).');
545
+ } else {
546
+ if (!dryRun) {
547
+ fs.mkdirSync(destClaude, { recursive: true });
548
+ fs.writeFileSync(settingsDest, renderStarterSettings());
549
+ }
550
+ summary.settings = true;
551
+ summary.notes.push(`.claude/settings.json written from the starter — ${STARTER_ALLOW.length} blessed command(s), permissions only.`);
552
+ log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (starter: ${STARTER_ALLOW.length} blessed commands, no hooks)`);
553
+ }
488
554
  }
489
555
 
490
556
  // 3. hooks — topology; copied WITH an identity rebrand (their .js internals carried
@@ -650,4 +716,4 @@ if (require.main === module) {
650
716
  process.exit(s.refused.length ? 1 : 0);
651
717
  }
652
718
 
653
- module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
719
+ module.exports = { scriptToVerb, bongosify, transformSettings, renderStarterSettings, STARTER_ALLOW, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
@@ -9,7 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
12
- const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
12
+ const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal, staleSummaryNotice } = require('./ship-honesty.js');
13
13
  const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
14
14
  const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
15
15
  const { matchOne } = require('../../src/bongos/path-match');
@@ -826,6 +826,12 @@ async function regradeMain() {
826
826
  // PATCH /tasks/:id is the route it uses, and `value_summary` was added to that
827
827
  // schema by this same task. tests/ship_cannot_lie.mjs pins the wiring.
828
828
  const summary = summaryArg || task.value_summary || '';
829
+ // task 1003693: the fallback above is silent, and on a re-grade silence means
830
+ // re-publishing the failed attempt's sentence. Say so while it can still be
831
+ // corrected — see staleSummaryNotice for the incident this comes from.
832
+ if (!summaryArg) {
833
+ for (const line of staleSummaryNotice(taskId, task.value_summary)) console.log(line);
834
+ }
829
835
 
830
836
  // The grader helpers want a claim-like object for touches[] (preflight's
831
837
  // working-tree-drift check) and head_sha (baseline fallback). The claim is
@@ -195,9 +195,63 @@ function regradeNotesRefusal(taskId) {
195
195
  ];
196
196
  }
197
197
 
198
+ // THE FOURTH WAY TO LOSE THE BUILDER'S PROSE — and the only one where what
199
+ // survives is worse than nothing, because it is a sentence that was true once.
200
+ //
201
+ // regradeMain falls back to the stored value_summary when --summary is omitted
202
+ // (`summaryArg || task.value_summary`), and persistRegradeSummary deliberately
203
+ // writes nothing on an omission. Both postures are right alone: a re-grade with
204
+ // no summary must not blank the row, and an empty broadcast would be worse than
205
+ // a stale one. Together they mean the DEFAULT re-grade re-publishes the first
206
+ // attempt's sentence with nothing saying so.
207
+ //
208
+ // That is the task-1002343 incident exactly. Shipped 2026-09-04 asserting "No
209
+ // project was created and nothing on the live system was left changed"; parked
210
+ // at completed on a failing grade; redone 2026-09-07, when a project WAS created
211
+ // (instance 40, r24-proof). The re-grade omitted --summary, so the ledger and the
212
+ // #ship-news post both announced the opposite of what landed. Nobody ignored a
213
+ // correction — there was no prompt to make one.
214
+ //
215
+ // So this is a NOTICE, not a refusal and not a behaviour change. The fallback is
216
+ // correct and stays; task 1003817 already built the correction path (--summary on
217
+ // a re-grade now reaches the row, the hall, /status and the broadcast). What was
218
+ // missing is the moment that tells the builder that path exists, while the run
219
+ // can still be corrected. Printed BEFORE the grade spend for the reason the
220
+ // --notes refusal is: a warning that first costs a 4-worker panel run is barely
221
+ // a warning at all.
222
+ //
223
+ // Returns [] when the row holds no stored summary — there is no stale text to
224
+ // warn about then, and an empty summary is the merge leg's problem, not this
225
+ // one. Pure, so the WORDING is testable: a notice that fails to name the fix is
226
+ // noise with a warning sign on it.
227
+ function staleSummaryNotice(taskId, storedSummary) {
228
+ // Control characters are stripped BEFORE the whitespace collapse, which only
229
+ // matches \s and would leave an ESC through. The stored summary is builder prose
230
+ // that has already been server-side stored and is already bound for Discord and
231
+ // the hall, so this crosses no new trust boundary — but this is the one place it
232
+ // reaches a TERMINAL, where an escape sequence could repaint the notice around it.
233
+ const stored = String(storedSummary || '')
234
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
235
+ .replace(/\s+/g, ' ')
236
+ .trim();
237
+ if (!stored) return [];
238
+ const shown = stored.length > 140 ? `${stored.slice(0, 139)}…` : stored;
239
+ return [
240
+ '⚠ No --summary given — this re-grade reuses the summary stored at the FIRST ship:',
241
+ ` "${shown}"`,
242
+ ' That sentence describes the attempt that FAILED its grade. A re-grade usually means the',
243
+ ' work changed since, and this text is what the panel reads, what the merge commit and PR',
244
+ ' say, and what goes to Discord #ship-news as the durable record of this ship.',
245
+ '',
246
+ ' If it still describes what landed, ignore this. If not, correct it in this same run:',
247
+ ` node scripts/gds/ship.js ${taskId} --regrade --summary-file summary.txt`,
248
+ ' (the -file form is the safe one for prose — a shell eats backticks in an inline value)',
249
+ ];
250
+ }
251
+
198
252
  module.exports = {
199
253
  resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
200
- proseFlagPresent, regradeNotesRefusal,
254
+ proseFlagPresent, regradeNotesRefusal, staleSummaryNotice,
201
255
  _resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
202
256
  _peekForTest: () => shipProgress,
203
257
  };
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.793'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.795'; // 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');
@@ -26,6 +26,9 @@ const require = createRequire(import.meta.url);
26
26
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
27
27
  const m = require('../scripts/gds/claude-materialize.js');
28
28
  const claim = require('../scripts/gds/claim.js');
29
+ // The live verb table, so a starter allow-rule naming a verb that does not exist
30
+ // fails here rather than shipping as an inert permission (task 1003962).
31
+ const { VERBS: cliVerbs } = require('../bin/bongos.js');
29
32
 
30
33
  let passed = 0;
31
34
  let failed = 0;
@@ -704,5 +707,73 @@ t('materializeModeSkills lands the real module-owned skills, self-ignored, and i
704
707
  rmSync(core, { recursive: true, force: true });
705
708
  });
706
709
 
710
+ // ---------------------------------------------------------------------------
711
+ // task 1003962 — the starter settings.json for a package-only install.
712
+ //
713
+ // THE GAP: `.claude/skills/` ships in the core package but `.claude/settings.json`
714
+ // is excluded as topology, so an instance received the methodology and none of the
715
+ // permissions that let it run — every blessed command met the auto-mode classifier.
716
+ // ---------------------------------------------------------------------------
717
+
718
+ t('the starter carries PERMISSIONS ONLY — no hooks, no env (those are topology)', () => {
719
+ const parsed = JSON.parse(m.renderStarterSettings());
720
+ assert.ok(Array.isArray(parsed.permissions.allow), 'must carry permissions.allow');
721
+ assert.ok(!('hooks' in parsed), 'hooks are gate machinery and must NOT ship to an instance');
722
+ assert.ok(!('env' in parsed), 'env is topology and must NOT ship to an instance');
723
+ assert.ok(parsed._doc.includes('grants no authority'),
724
+ 'the file must say out loud that it grants no authority (ADR 0016)');
725
+ });
726
+
727
+ t('every allow-rule names a verb the CLI actually registers', () => {
728
+ // The drift this catches: a rule naming a verb that was renamed or never existed
729
+ // is silently inert — it looks like a permission and grants nothing, so the
730
+ // builder still meets the classifier and nobody can see why.
731
+ for (const rule of m.STARTER_ALLOW) {
732
+ const inner = rule.replace(/^Bash\(/, '').replace(/:\*\)$/, '');
733
+ const verb = inner.split(' ')[1];
734
+ assert.ok(cliVerbs[verb], `allow-rule "${rule}" names "${verb}", which is not a registered bongos verb`);
735
+ }
736
+ });
737
+
738
+ t('the escape hatch is NOT blanket-allowed', () => {
739
+ // `bongos exec` runs any script inside the core by path. Allow-listing it bare
740
+ // would be a blanket grant wearing a narrow rule's clothes.
741
+ assert.ok(!m.STARTER_ALLOW.includes('Bash(bongos exec:*)'),
742
+ 'bongos exec must never be allowed bare');
743
+ const execRules = m.STARTER_ALLOW.filter((r) => r.includes('bongos exec'));
744
+ for (const r of execRules) {
745
+ assert.match(r, /bongos exec scripts\/gds\/[a-z-]+\.js/,
746
+ `an exec rule must name its script in full, got: ${r}`);
747
+ }
748
+ });
749
+
750
+ t('a package-only install (no settings.json at the core root) GETS the starter', () => {
751
+ const core = mkdtempSync(join(tmpdir(), 'w3-starter-core-'));
752
+ const inst = mkdtempSync(join(tmpdir(), 'w3-starter-inst-'));
753
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true }); // skills ship; settings.json does not
754
+ const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
755
+ assert.equal(res.settings, true, 'materialize should report it wrote settings');
756
+ const written = join(inst, '.claude', 'settings.json');
757
+ assert.ok(existsSync(written), 'instance must receive a starter settings.json');
758
+ const parsed = JSON.parse(readFileSync(written, 'utf8'));
759
+ assert.deepEqual(parsed.permissions.allow, m.STARTER_ALLOW);
760
+ rmSync(core, { recursive: true, force: true });
761
+ rmSync(inst, { recursive: true, force: true });
762
+ });
763
+
764
+ t("an instance's OWN settings.json is never clobbered", () => {
765
+ const core = mkdtempSync(join(tmpdir(), 'w3-keep-core-'));
766
+ const inst = mkdtempSync(join(tmpdir(), 'w3-keep-inst-'));
767
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
768
+ mkdirSync(join(inst, '.claude'), { recursive: true });
769
+ const mine = JSON.stringify({ permissions: { allow: ['Bash(echo hello:*)'] } }, null, 2);
770
+ writeFileSync(join(inst, '.claude', 'settings.json'), mine);
771
+ m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
772
+ assert.equal(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'), mine,
773
+ 'an upgrade must never overwrite permissions the instance has grown');
774
+ rmSync(core, { recursive: true, force: true });
775
+ rmSync(inst, { recursive: true, force: true });
776
+ });
777
+
707
778
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
708
779
  process.exit(failed ? 1 : 0);
@@ -17,6 +17,10 @@
17
17
  //
18
18
  // (3) --regrade must not swallow --notes. Same shape as (1): prose handed to
19
19
  // ship.js that reaches nothing, silently (task 1003702).
20
+ //
21
+ // (4) --regrade must not silently REUSE the failed attempt's summary. The same
22
+ // silence as (3) from the other side: prose the builder never handed over,
23
+ // published as though they had (task 1003693).
20
24
  import { strict as assert } from 'node:assert';
21
25
  import { test } from 'node:test';
22
26
  import { createRequire } from 'node:module';
@@ -26,7 +30,7 @@ const require = createRequire(import.meta.url);
26
30
  const { argText } = require('../scripts/gds/cli-lib.js');
27
31
  const {
28
32
  fatalRecoveryLines, markShipProgress, _peekForTest,
29
- proseFlagPresent, regradeNotesRefusal,
33
+ proseFlagPresent, regradeNotesRefusal, staleSummaryNotice,
30
34
  } = require('../scripts/gds/ship-honesty.js');
31
35
 
32
36
  // Injected IO so no case touches the filesystem.
@@ -297,3 +301,73 @@ test('a re-grade that omits --summary leaves the stored value_summary alone', ()
297
301
  // And a failed write never aborts the re-grade — no process.exit, no throw.
298
302
  assert.ok(!/process\.exit/.test(code), 'a summary that will not persist must not kill the re-grade');
299
303
  });
304
+
305
+ // ── (4) an omitted --summary on a re-grade says so (task 1003693) ────────────
306
+ //
307
+ // The sibling of the ACCEPTANCE 2 test above. That one pins that an omitted
308
+ // --summary does not OVERWRITE the row; this one pins that it does not pass in
309
+ // SILENCE. Both postures were already right and the gap was between them: the
310
+ // default re-grade re-publishes the failed attempt's sentence to #ship-news
311
+ // with nothing saying so (task 1002343, 2026-09-07).
312
+
313
+ test('the stale-summary notice quotes the sentence and names the fix', () => {
314
+ const lines = staleSummaryNotice(1002343, 'No project was created and nothing on the live system was left changed.');
315
+ const text = lines.join('\n');
316
+ // The builder cannot judge a summary they are not shown — the whole point is
317
+ // that the stored sentence is plausible until you read it next to the work.
318
+ assert.match(text, /No project was created and nothing on the live system was left changed\./,
319
+ 'quotes the stored sentence verbatim');
320
+ // A notice that names no fix is noise with a warning sign on it.
321
+ assert.match(text, /--regrade --summary-file/, 'names the correcting flag');
322
+ assert.match(text, /ship\.js 1002343 --regrade/, 'as a runnable command with the real task id');
323
+ // WHY it matters: this text becomes the durable public record, not just a log line.
324
+ assert.match(text, /#ship-news/, 'says where the sentence ends up');
325
+ assert.match(text, /FAILED its grade/, 'says which attempt the stored sentence describes');
326
+ });
327
+
328
+ test('the notice stays silent when there is no stored summary to go stale', () => {
329
+ // No stored text means nothing can be re-published wrongly; an empty summary
330
+ // is the merge leg's problem. Warning here would train the builder to skip it.
331
+ for (const empty of ['', ' ', null, undefined]) {
332
+ assert.deepEqual(staleSummaryNotice(1, empty), [], `silent for ${JSON.stringify(empty)}`);
333
+ }
334
+ });
335
+
336
+ test('the quoted sentence is always one readable line', () => {
337
+ // value_summary allows up to LIMITS.VALUE_SUMMARY characters and may hold
338
+ // newlines. Either would break the quoted line out of the notice block and
339
+ // bury the runnable command that follows it.
340
+ const long = staleSummaryNotice(7, 'x'.repeat(400));
341
+ assert.ok(long[1].length < 160, 'a long summary is truncated');
342
+ assert.match(long[1], /…/, 'and says it was truncated');
343
+ const multi = staleSummaryNotice(9, 'line one\n\nline two\ttabbed');
344
+ assert.equal(multi[1].split('\n').length, 1, 'newlines collapse');
345
+ assert.match(multi[1], /"line one line two tabbed"/, 'into single spaces');
346
+
347
+ // This is the one place a stored summary reaches a TERMINAL. \s does not match
348
+ // ESC, so the collapse above would pass an escape sequence straight through and
349
+ // let it repaint the notice around itself.
350
+ const esc = staleSummaryNotice(11, 'safe\u001b[31mred\u0007 text');
351
+ assert.ok(!/[\u0000-\u001f\u007f-\u009f]/.test(esc.join(' ')),
352
+ 'terminal control characters are stripped before display');
353
+ assert.match(esc[1], /"safe \[31mred text"/, 'and the readable text survives');
354
+ });
355
+
356
+ test('the notice is wired into the regrade path, before any grade spend', () => {
357
+ // The (1) lesson again: a helper nothing calls is not a fix. And the ORDER is
358
+ // load-bearing for the same reason the --notes refusal's is — a warning the
359
+ // builder reads only after a 4-worker panel has run cannot change that run.
360
+ const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
361
+ const regrade = src.slice(src.indexOf('async function regradeMain'));
362
+ const code = regrade.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not print
363
+
364
+ const call = code.indexOf('staleSummaryNotice(taskId, task.value_summary)');
365
+ assert.ok(call !== -1, 'regradeMain emits the notice');
366
+ // Keyed on the RAW flag, like persistRegradeSummary — `summary` has already
367
+ // absorbed the fallback by this point, so testing it would never be falsy.
368
+ // Brace-form or one-liner, but it must branch on summaryArg and nothing else.
369
+ assert.match(code, /if \(!summaryArg\)\s*\{?\s*(\n\s*)?for \(const line of staleSummaryNotice/,
370
+ 'it fires on the raw omitted flag, not the resolved fallback');
371
+ const preflight = code.indexOf('preflight(claimLike');
372
+ assert.ok(preflight !== -1 && call < preflight, 'and prints before the preflight and the panel');
373
+ });
@@ -0,0 +1,163 @@
1
+ // tests/skill_friction_kind.mjs
2
+ //
3
+ // 'skill-friction' as a real idea kind, and the one predicate that hides those
4
+ // rows from a Xenos (task 1003105, goal 1000095, from idea 1000759).
5
+ //
6
+ // Two things are pinned here, and they fail in different directions.
7
+ //
8
+ // THE DRIFT TRIPWIRE. `VALID_KINDS` in JS and the CHECK constraint in SQL are
9
+ // two lists that must say the same thing, and nothing made them. Widening one
10
+ // and not the other is silent in both directions: a kind added only to JS gets
11
+ // past validation and dies on INSERT with a constraint violation instead of a
12
+ // clean BAD_KIND, and a kind added only to SQL is unreachable. That is the exact
13
+ // shape of the bug this task fixed, so the fix ships with the tripwire.
14
+ //
15
+ // THE FOUR GATES. Skill-friction rows are hidden from Xenos-rank builders in
16
+ // four separate read paths. They used to be four hand-copied conditions testing
17
+ // a title prefix; now that the marker can ALSO be a kind, four copies would be
18
+ // four chances for one to learn about the kind and the others not to — and a
19
+ // gate that silently stops hiding is invisible until the wrong reader sees the
20
+ // row. So there is one predicate, and these tests hold it to both markers, to
21
+ // correct bind-parameter numbering, and to costing nothing for a viewer who may
22
+ // see everything.
23
+ //
24
+ // Pure: no DB, no network.
25
+ //
26
+ // Run: node tests/skill_friction_kind.mjs
27
+
28
+ import { strict as assert } from 'node:assert';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const inbox = require('../modules/ideas/inbox.js');
36
+
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+
39
+ // The migration that currently defines the constraint. If a later migration
40
+ // widens it again, point this at that file — forcing that edit is the job.
41
+ const CONSTRAINT_MIGRATION = path.join(
42
+ ROOT, 'migrations', 'core_244_skill_friction_kind.sql'
43
+ );
44
+
45
+ let passed = 0;
46
+ let failed = 0;
47
+ function test(name, fn) {
48
+ try {
49
+ fn();
50
+ passed++;
51
+ console.log(` ok ${name}`);
52
+ } catch (err) {
53
+ failed++;
54
+ console.log(` FAIL ${name}`);
55
+ console.log(` ${err.message}`);
56
+ }
57
+ }
58
+
59
+ // ---- the kind exists at all ------------------------------------------------
60
+
61
+ test('skill-friction is a valid kind, so the two skills that file one stop failing BAD_KIND', () => {
62
+ assert.ok(
63
+ inbox.VALID_KINDS.includes('skill-friction'),
64
+ "/planning-session and /priority-session both instruct kind='skill-friction'"
65
+ );
66
+ assert.equal(inbox.SKILL_FRICTION_KIND, 'skill-friction');
67
+ });
68
+
69
+ test('widening the enum did not drop anything that was already in it', () => {
70
+ for (const k of [
71
+ 'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
72
+ 'cleanup', 'decision', 'blocker-resolution', 'unclassified', 'criterion-proposal',
73
+ ]) {
74
+ assert.ok(inbox.VALID_KINDS.includes(k), `${k} missing from VALID_KINDS`);
75
+ }
76
+ });
77
+
78
+ // ---- JS and SQL say the same thing -----------------------------------------
79
+
80
+ test('the SQL CHECK constraint and VALID_KINDS list exactly the same kinds', () => {
81
+ const sql = fs.readFileSync(CONSTRAINT_MIGRATION, 'utf8');
82
+ const m = /CHECK\s*\(\s*kind\s+IN\s*\(([\s\S]*?)\)\s*\)/i.exec(sql);
83
+ assert.ok(m, 'the migration declares a CHECK (kind IN (...)) constraint');
84
+
85
+ const fromSql = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
86
+ assert.ok(fromSql.length > 0, 'the constraint lists at least one kind');
87
+
88
+ const a = [...fromSql].sort();
89
+ const b = [...inbox.VALID_KINDS].sort();
90
+ assert.deepEqual(
91
+ a, b,
92
+ `SQL and JS disagree — only in SQL: [${a.filter((k) => !b.includes(k))}], ` +
93
+ `only in JS: [${b.filter((k) => !a.includes(k))}]`
94
+ );
95
+ });
96
+
97
+ // ---- the visibility gate ---------------------------------------------------
98
+
99
+ test('a viewer who may see everything costs no clause and no bind parameters', () => {
100
+ for (const rank of [null, undefined, 'metic', 'archon', 'Archon', 'something-new']) {
101
+ const params = [];
102
+ assert.equal(inbox.skillFrictionHiddenClause(rank, params), null, `rank=${rank}`);
103
+ assert.equal(params.length, 0, `rank=${rank} pushed no params`);
104
+ }
105
+ });
106
+
107
+ test('a Xenos is hidden from BOTH markers — the kind and the legacy title prefix', () => {
108
+ const params = [];
109
+ const clause = inbox.skillFrictionHiddenClause('xenos', params);
110
+ assert.ok(clause, 'a Xenos gets a clause');
111
+ assert.match(clause, /kind <> \$1/, 'the kind marker is tested');
112
+ assert.match(clause, /title NOT LIKE \$2/, 'the legacy prefix marker is tested too');
113
+ assert.deepEqual(params, [inbox.SKILL_FRICTION_KIND, `${inbox.SKILL_FRICTION_TAG}%`]);
114
+ });
115
+
116
+ test('the rank test is case-insensitive, so a capitalised Xenos is still hidden', () => {
117
+ const params = [];
118
+ assert.ok(inbox.skillFrictionHiddenClause('XENOS', params), 'XENOS is still a Xenos');
119
+ assert.equal(inbox.rankCanSeeSkillFriction('Xenos'), false);
120
+ assert.equal(inbox.rankCanSeeSkillFriction('metic'), true);
121
+ assert.equal(inbox.rankCanSeeSkillFriction(null), true);
122
+ });
123
+
124
+ test('bind parameters are numbered from where the caller already was, not from 1', () => {
125
+ const params = ['open', 42];
126
+ const clause = inbox.skillFrictionHiddenClause('xenos', params, 'i');
127
+ assert.match(clause, /i\.kind <> \$3/, 'continues the caller\'s numbering');
128
+ assert.match(clause, /i\.title NOT LIKE \$4/);
129
+ assert.equal(params.length, 4, 'and appended exactly two');
130
+ });
131
+
132
+ test('the table alias is applied to both columns or to neither', () => {
133
+ const withAlias = inbox.skillFrictionHiddenClause('xenos', [], 'i');
134
+ assert.match(withAlias, /i\.kind/);
135
+ assert.match(withAlias, /i\.title/);
136
+ const bare = inbox.skillFrictionHiddenClause('xenos', []);
137
+ assert.doesNotMatch(bare, /\bi\./, 'no alias means no alias on either column');
138
+ assert.match(bare, /\bkind <>/);
139
+ assert.match(bare, /\btitle NOT LIKE/);
140
+ });
141
+
142
+ test('the clause is parenthesised, so ANDing it into a WHERE cannot change the other terms', () => {
143
+ const clause = inbox.skillFrictionHiddenClause('xenos', []);
144
+ assert.ok(clause.startsWith('(') && clause.endsWith(')'), `not parenthesised: ${clause}`);
145
+ assert.match(clause, / AND /, 'both markers must hold for the row to be shown');
146
+ });
147
+
148
+ // ---- the legacy marker is not quietly retired ------------------------------
149
+
150
+ test('the legacy title prefix is still exported and still the nightly routine\'s marker', () => {
151
+ assert.equal(inbox.SKILL_FRICTION_TAG, '[skill-friction]');
152
+ const routine = fs.readFileSync(
153
+ path.join(ROOT, '.claude', 'scheduled-tasks', 'skill-usage-review', 'SKILL.md'), 'utf8'
154
+ );
155
+ assert.ok(
156
+ routine.includes('[skill-friction]'),
157
+ 'skill-usage-review still tags the prefix — the gate must keep recognising it'
158
+ );
159
+ });
160
+
161
+ console.log('');
162
+ console.log(`skill-friction kind + visibility gate (task 1003105): ${passed} passed, ${failed} failed`);
163
+ if (failed > 0) process.exit(1);