@rubytech/create-maxy-code 0.1.460 → 0.1.461

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.
Files changed (63) hide show
  1. package/dist/__tests__/cron-heartbeat-registration.test.js +57 -28
  2. package/dist/__tests__/launchd-plist.test.js +26 -0
  3. package/dist/cron-registration.js +30 -8
  4. package/dist/index.js +50 -9
  5. package/dist/launchd-plist.js +1 -1
  6. package/dist/uninstall.js +9 -3
  7. package/package.json +1 -1
  8. package/payload/platform/plugins/cloudflare/bin/portal-enrol.mjs +96 -0
  9. package/payload/platform/plugins/cloudflare/mcp/__tests__/auth-route.test.ts +54 -40
  10. package/payload/platform/plugins/cloudflare/mcp/__tests__/passcode.test.ts +169 -39
  11. package/payload/platform/plugins/cloudflare/mcp/__tests__/portal-enrol.test.ts +207 -0
  12. package/payload/platform/plugins/cloudflare/mcp/package.json +1 -1
  13. package/payload/platform/plugins/cloudflare/skills/data-portal/SKILL.md +21 -19
  14. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.mjs +193 -0
  15. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/auth.ts +18 -12
  16. package/payload/platform/plugins/cloudflare/skills/data-portal/template/schema.sql +14 -5
  17. package/payload/platform/plugins/voice-mirror/PLUGIN.md +1 -1
  18. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.d.ts.map +1 -1
  19. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js +98 -9
  20. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-distil-profile.js.map +1 -1
  21. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.d.ts.map +1 -1
  22. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js +54 -21
  23. package/payload/platform/plugins/voice-mirror/mcp/dist/tools/voice-retrieve-conditioning.js.map +1 -1
  24. package/payload/platform/plugins/voice-mirror/mcp/scripts/smoke.mjs +110 -0
  25. package/payload/platform/plugins/voice-mirror/skills/voice-mirror/SKILL.md +6 -3
  26. package/payload/platform/scripts/__tests__/logs-rotate.test.sh +256 -0
  27. package/payload/platform/scripts/__tests__/resume-tunnel.test.sh +79 -0
  28. package/payload/platform/scripts/logs-rotate.sh +204 -0
  29. package/payload/platform/scripts/resume-tunnel.sh +27 -0
  30. package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
  31. package/payload/platform/services/claude-session-manager/dist/http-server.js +41 -5
  32. package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
  33. package/payload/platform/services/claude-session-manager/dist/index.d.ts +1 -0
  34. package/payload/platform/services/claude-session-manager/dist/index.d.ts.map +1 -1
  35. package/payload/platform/services/claude-session-manager/dist/index.js +22 -0
  36. package/payload/platform/services/claude-session-manager/dist/index.js.map +1 -1
  37. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts +2 -0
  38. package/payload/platform/services/claude-session-manager/dist/install-start-counter.d.ts.map +1 -0
  39. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js +12 -0
  40. package/payload/platform/services/claude-session-manager/dist/install-start-counter.js.map +1 -0
  41. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts +122 -9
  42. package/payload/platform/services/claude-session-manager/dist/pty-census.d.ts.map +1 -1
  43. package/payload/platform/services/claude-session-manager/dist/pty-census.js +214 -29
  44. package/payload/platform/services/claude-session-manager/dist/pty-census.js.map +1 -1
  45. package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
  46. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +2 -1
  47. package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
  48. package/payload/platform/services/claude-session-manager/dist/rc-daemon.d.ts.map +1 -1
  49. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js +13 -1
  50. package/payload/platform/services/claude-session-manager/dist/rc-daemon.js.map +1 -1
  51. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.d.ts.map +1 -1
  52. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js +4 -2
  53. package/payload/platform/services/claude-session-manager/dist/session-memory-cap.js.map +1 -1
  54. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts +150 -0
  55. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.d.ts.map +1 -0
  56. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js +333 -0
  57. package/payload/platform/services/claude-session-manager/dist/slice-memory-policy.js.map +1 -0
  58. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts +10 -0
  59. package/payload/platform/services/claude-session-manager/dist/start-counter.d.ts.map +1 -0
  60. package/payload/platform/services/claude-session-manager/dist/start-counter.js +55 -0
  61. package/payload/platform/services/claude-session-manager/dist/start-counter.js.map +1 -0
  62. package/payload/server/server.js +55 -35
  63. package/payload/platform/plugins/cloudflare/skills/data-portal/template/functions/api/_lib/passcode.ts +0 -73
@@ -10,9 +10,18 @@
10
10
  * - All callers updated in the same commit.
11
11
  *
12
12
  * Retrieval order (best to worst):
13
- * 1. If brief.topic is provided AND keyword search on the per-format corpus
14
- * returns ≥K matches use those (ordered by recency).
15
- * 2. Otherwise, return the K most-recent per-format corpus nodes.
13
+ * 1. Corpus nodes whose body carries the most `brief.topic` terms.
14
+ * 2. Within an equal term count, the most recent.
15
+ *
16
+ * `brief.topic` ranks; it never excludes. Until Task 1719 it was applied as
17
+ * `WHERE toLower(body) CONTAINS toLower($topic)` — a single literal
18
+ * whole-phrase match — so a multi-word topic ('programme outline training')
19
+ * matched no body and the walk returned zero exemplars over a non-empty
20
+ * corpus, while `status` still read "ok" because a style card alone satisfies
21
+ * `hasData`. Ranking honours this header's original intent (topic hits first,
22
+ * K backfilled by recency) in one pass: a voice profile conditions on how the
23
+ * operator writes, not what about, so an off-topic exemplar still carries the
24
+ * signal and is always worth more than nothing.
16
25
  *
17
26
  * Embeddings are not used in v1: the corpus is small (single operator's
18
27
  * personal writing — typically <500 docs), and recency-with-keyword-filter
@@ -36,6 +45,16 @@ const CHARS_PER_TOKEN = 4;
36
45
  function errorResult(error) {
37
46
  return { styleCard: null, exemplars: [], status: "error", error };
38
47
  }
48
+ /**
49
+ * Cypher `size()` arrives as a driver Integer, not a JS number, and an Integer
50
+ * is truthy at zero — so comparing the raw value would count every row as a
51
+ * topic hit. Narrow it before any arithmetic.
52
+ */
53
+ function toNumber(value) {
54
+ if (neo4j.isInt(value))
55
+ return value.toNumber();
56
+ return typeof value === "number" ? value : 0;
57
+ }
39
58
  export async function voiceRetrieveConditioning(params) {
40
59
  const { accountId, userId, brief } = params;
41
60
  const scope = brief.scope ?? "personal";
@@ -55,12 +74,18 @@ export async function voiceRetrieveConditioning(params) {
55
74
  (length === "long" ? DEFAULT_TOKEN_BUDGET_LONG : DEFAULT_TOKEN_BUDGET_SHORT);
56
75
  const charBudget = tokenBudget * CHARS_PER_TOKEN;
57
76
  const format = brief.format;
58
- const topic = (brief.topic ?? "").trim();
59
- const usesTopic = topic.length > 0;
77
+ // Distinct lowercased topic terms. Lowercasing happens here, once, so the
78
+ // Cypher compares term-to-body without a per-row toLower on the term. An
79
+ // absent/blank topic yields [], which scores every row 0 and leaves the
80
+ // ordering to recency alone — the same walk, no branch.
81
+ const topicTerms = Array.from(new Set((brief.topic ?? "")
82
+ .toLowerCase()
83
+ .split(/\s+/)
84
+ .filter((t) => t.length > 0)));
60
85
  // Body resolution (Task 471): for :KnowledgeDocument with HAS_SECTION
61
86
  // children, concatenate child :Section bodies in `position` order (KD.body is
62
87
  // null after Task 465 server-slicing); other labels read n.body via the
63
- // COALESCE chain. The topic filter runs against the computed body.
88
+ // COALESCE chain. Topic ranking scores against the computed body.
64
89
  const bodyComputeBlock = `WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
65
90
  OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
66
91
  WHERE ${notTrashed("s")}
@@ -86,30 +111,29 @@ export async function voiceRetrieveConditioning(params) {
86
111
  const profile = await session.run(`OPTIONAL MATCH (p:VoiceProfile {accountId: $accountId, userId: $profileUserId, format: $format})
87
112
  RETURN p.styleCard AS styleCard`, { accountId, profileUserId, format });
88
113
  const styleCard = profile.records[0]?.get("styleCard") ?? null;
89
- const cypher = usesTopic
90
- ? `MATCH (n)
91
- WHERE ${exemplarWhere}
92
- ${bodyComputeBlock}
93
- WHERE toLower(body) CONTAINS toLower($topic)
94
- RETURN elementId(n) AS id, labels(n) AS labels, body,
95
- coalesce(n.subject, n.title, n.name, '') AS source, ts
96
- ORDER BY ts IS NULL, ts DESC
97
- LIMIT $k`
98
- : `MATCH (n)
114
+ // `topicScore` counts how many distinct topic terms the body carries and
115
+ // orders on it. It is a rank, not a predicate: a row scoring 0 stays in
116
+ // the result set and is simply ordered behind the hits, so the walk
117
+ // returns up to K exemplars whenever the corpus holds any, whatever the
118
+ // topic. `ts IS NULL` keeps undated nodes last within an equal score.
119
+ const cypher = `MATCH (n)
99
120
  WHERE ${exemplarWhere}
100
121
  ${bodyComputeBlock}
122
+ WITH n, ts, body,
123
+ size([t IN $topicTerms WHERE toLower(body) CONTAINS t]) AS topicScore
101
124
  RETURN elementId(n) AS id, labels(n) AS labels, body,
102
- coalesce(n.subject, n.title, n.name, '') AS source, ts
103
- ORDER BY ts IS NULL, ts DESC
125
+ coalesce(n.subject, n.title, n.name, '') AS source, ts, topicScore
126
+ ORDER BY topicScore DESC, ts IS NULL, ts DESC
104
127
  LIMIT $k`;
105
128
  const result = await session.run(cypher, {
106
129
  ...exemplarParams,
107
- topic,
130
+ topicTerms,
108
131
  // neo4j-driver serializes a plain JS number as a Cypher float (15 →
109
132
  // 15.0), which LIMIT rejects. Send a Neo4j integer.
110
133
  k: neo4j.int(k),
111
134
  });
112
135
  let charsUsed = 0;
136
+ let topicHits = 0;
113
137
  const exemplars = [];
114
138
  for (const r of result.records) {
115
139
  const body = r.get("body") ?? "";
@@ -128,8 +152,10 @@ export async function voiceRetrieveConditioning(params) {
128
152
  source: (r.get("source") ?? "").slice(0, 200),
129
153
  });
130
154
  charsUsed += truncated.length;
155
+ if (toNumber(r.get("topicScore")) > 0)
156
+ topicHits += 1;
131
157
  }
132
- return { styleCard, exemplars };
158
+ return { styleCard, exemplars, topicHits };
133
159
  };
134
160
  const hasData = (r) => (r.styleCard !== null && r.styleCard.length > 0) || r.exemplars.length > 0;
135
161
  const orgLookup = () => lookup(ORG_USER_ID, voiceCorpusWhereWithFormat(format), { accountId, format });
@@ -154,8 +180,15 @@ export async function voiceRetrieveConditioning(params) {
154
180
  result = org;
155
181
  status = hasData(org) ? "ok" : "no-data";
156
182
  }
183
+ // `topicTerms`/`topicHits` are the pair that makes a thin result legible
184
+ // without reproducing it: exemplarCount=0 says the scope's corpus is empty,
185
+ // and topicHits=0 with topicTerms>0 says the exemplars are recency-picked
186
+ // rather than topic-matched. Before Task 1719 the two were indistinguishable
187
+ // — a topic that matched nothing and an absent corpus both logged
188
+ // exemplarCount=0, and only the first was a defect.
157
189
  process.stderr.write(`[voice-retrieve] scope=${scope} format=${format} status=${status} ` +
158
- `styleCardBytes=${result.styleCard?.length ?? 0} exemplarCount=${result.exemplars.length} tokenBudget=${tokenBudget}\n`);
190
+ `styleCardBytes=${result.styleCard?.length ?? 0} exemplarCount=${result.exemplars.length} ` +
191
+ `topicTerms=${topicTerms.length} topicHits=${result.topicHits} tokenBudget=${tokenBudget}\n`);
159
192
  return { styleCard: result.styleCard, exemplars: result.exemplars, status };
160
193
  }
161
194
  catch (err) {
@@ -1 +1 @@
1
- {"version":3,"file":"voice-retrieve-conditioning.js","sourceRoot":"","sources":["../../src/tools/voice-retrieve-conditioning.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"voice-retrieve-conditioning.js","sourceRoot":"","sources":["../../src/tools/voice-retrieve-conditioning.ts"],"names":[],"mappings":"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"}
@@ -644,6 +644,63 @@ check("voice-distil-profile keeps both structural write anchors", () => {
644
644
  assert.equal(anchors?.length, 2, "both write-path anchors retained");
645
645
  });
646
646
 
647
+ check("both write paths aggregate the sample link in a CALL subquery", () => {
648
+ // The load-bearing invariant of the zero-record fix. A bare
649
+ // `WITH p UNWIND $ids ... RETURN elementId(p)` tail collapses the row when the
650
+ // id list is empty or every id is stale, so records.length === 0 stops meaning
651
+ // "the anchor missed" and the write reports an identity error for what is a
652
+ // content condition. The CALL subquery's bare aggregation returns a row over
653
+ // empty input, which is what keeps the outer row alive. Reverting either tail
654
+ // reintroduces the bug silently, so pin the shape here.
655
+ const subqueries = distilSrc.match(/RETURN count\(\*\) AS learned/g);
656
+ assert.equal(subqueries?.length, 2, "both write paths aggregate with count(*)");
657
+ // Positive form: each sample UNWIND must sit inside a CALL block. A negative
658
+ // "no bare UNWIND" regex cannot tell the subquery's own UNWIND from an outer
659
+ // one, so it fires on correct code.
660
+ const wrapped = distilSrc.match(
661
+ /CALL \{\s*\n\s*WITH p\s*\n\s*UNWIND \$(sampleIds|amendIds) AS sid/g,
662
+ );
663
+ assert.equal(wrapped?.length, 2, "both sample links are wrapped in a CALL subquery");
664
+ });
665
+
666
+ check("the zero-resolved write reports content, never identity", () => {
667
+ // The whole point of the fix: a merged profile with no sources must not reuse
668
+ // the unknown-:AdminUser wording, and must not tell the caller their id is
669
+ // wrong. Both messages disclaim identity explicitly.
670
+ // The messages are built by concatenating template literals, so a sentence can
671
+ // straddle a `+ ` join in source while being whole at runtime. Flatten the
672
+ // joins before matching prose, or these assertions test the formatting rather
673
+ // than the message.
674
+ const flat = distilSrc.replace(/`\s*\+\s*\n\s*`/g, "");
675
+ const disclaimers = flat.match(
676
+ /This is a content condition, not an identity one\./g,
677
+ );
678
+ assert.equal(disclaimers?.length, 2, "both zero-resolved messages disclaim identity");
679
+ assert.ok(
680
+ /none carried readable text to sample/.test(flat),
681
+ "the empty-sample cause has its own wording",
682
+ );
683
+ assert.ok(
684
+ /stopped resolving/.test(flat),
685
+ "the vanished-samples cause has its own wording",
686
+ );
687
+ });
688
+
689
+ check("the vanished-samples message does not blame trashing", () => {
690
+ // A trashed node keeps its elementId, and the write resolves with a bare
691
+ // `MATCH (s) WHERE elementId(s) = sid` carrying no trash predicate, so it
692
+ // still resolves and still takes its edge. Only a hard delete can produce the
693
+ // condition. Naming trashing sends the operator to a trash that cannot hold
694
+ // the cause, which is the same misattribution the fix removes.
695
+ const at = distilSrc.indexOf("function noSourcesResolvedMessage");
696
+ assert.ok(at > -1, "noSourcesResolvedMessage present");
697
+ const body = distilSrc.slice(at, at + 1200);
698
+ assert.ok(
699
+ !/deleted or trashed/.test(body),
700
+ "must not name trashing as a cause of an unresolved id",
701
+ );
702
+ });
703
+
647
704
  const retrieveSrc = readFileSync(
648
705
  join(TOOLS_SRC, "voice-retrieve-conditioning.ts"),
649
706
  "utf-8",
@@ -685,6 +742,59 @@ check("voice-retrieve-conditioning never sets isError", () => {
685
742
  );
686
743
  });
687
744
 
745
+ // Comments stripped, so a check that forbids a Cypher clause cannot be tripped
746
+ // by documentation *describing* that clause. The header of
747
+ // voice-retrieve-conditioning.ts quotes the topic-rank predicate verbatim to
748
+ // explain why it went; that prose must not read as the predicate itself.
749
+ const retrieveCode = retrieveSrc
750
+ .replace(/\/\*[\s\S]*?\*\//g, "")
751
+ .replace(/^\s*\/\/.*$/gm, "");
752
+
753
+ check("voice-retrieve-conditioning topic ranks and never excludes", () => {
754
+ // The regression guard for the reported defect: brief.topic was applied as a
755
+ // literal whole-phrase predicate, so a multi-word topic matched no body and
756
+ // the walk returned zero exemplars over a non-empty corpus. Any predicate
757
+ // that tests the topic inside a WHERE reintroduces it, whether phrased on
758
+ // $topic or on the terms — the topic belongs in the ORDER BY alone.
759
+ assert.ok(
760
+ !/CONTAINS\s+toLower\(\$topic\)/.test(retrieveCode),
761
+ "topic must not filter the exemplar walk: a non-matching topic empties a non-empty corpus",
762
+ );
763
+ assert.ok(
764
+ !/WHERE[^\n]*\$topicTerms/.test(retrieveCode),
765
+ "topic terms must not appear in a WHERE that gates the walk, only in the ranked projection",
766
+ );
767
+ assert.ok(
768
+ /ORDER BY topicScore DESC/.test(retrieveCode),
769
+ "topic must rank the walk: topicScore orders, recency breaks the tie",
770
+ );
771
+ });
772
+
773
+ check("voice-retrieve-conditioning narrows Cypher integers before comparing", () => {
774
+ // A driver Integer is truthy at zero, so `topicScore > 0` on the raw value
775
+ // would count every row as a topic hit and make the observability line lie.
776
+ assert.ok(
777
+ /toNumber\(r\.get\("topicScore"\)\)\s*>\s*0/.test(retrieveSrc),
778
+ "topicScore must be narrowed via toNumber() before the hit comparison",
779
+ );
780
+ });
781
+
782
+ check("voice-retrieve-conditioning builds distinct lowercased topic terms", () => {
783
+ // Asserted on the source, never on dist/: dist is a build artifact that
784
+ // survives a source revert, so a check reading it passes against code that no
785
+ // longer exists. The parse is what turns a phrase into rankable terms —
786
+ // lowercased once here so the Cypher compares term-to-body directly, and
787
+ // de-duplicated so a repeated word cannot inflate topicScore.
788
+ assert.ok(
789
+ /\.toLowerCase\(\)[\s\S]{0,60}\.split\(\/\\s\+\/\)/.test(retrieveSrc),
790
+ "topic must be lowercased and split on whitespace into terms",
791
+ );
792
+ assert.ok(
793
+ /const topicTerms = Array\.from\(\s*new Set\(/.test(retrieveSrc),
794
+ "topic terms must be de-duplicated so a repeated word cannot inflate topicScore",
795
+ );
796
+ });
797
+
688
798
  // ---------------------------------------------------------------------------
689
799
  // 14. One refusal wording across every gate (Task 1701).
690
800
  // record-feedback and ingest already enforced the rule via an inline
@@ -115,7 +115,7 @@ This runs only on the admin seat (the tool is admin-allowlisted). Accounts witho
115
115
 
116
116
  ## Flow — drafting-time conditioning
117
117
 
118
- Drafting skills (email composition, Postiz, property-brochure, investor-data-room Stage 3 prose, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register, scope}` — where `format` is the target corpus format and `scope` picks whose voice to draft in. Default `scope` is `personal` (the calling operator's own voice); a drafting skill whose output goes out under the business name passes `scope: "org"` for the house voice. Email composition stays personal; property-brochure, the investor-data-room Stage 3 business plan, and the prospectus skills request `org`. It returns:
118
+ Drafting skills (email composition, Postiz, property-brochure, investor-data-room Stage 3 prose, prospectus) call `voice-retrieve-conditioning` transparently before assembling their prompt. The skill takes a brief — `{topic, format, length, register, scope}` — where `format` is the target corpus format and `scope` picks whose voice to draft in. Default `scope` is `personal` (the calling operator's own voice); a drafting skill whose output goes out under the business name passes `scope: "org"` for the house voice. Email composition stays personal; property-brochure, the investor-data-room Stage 3 business plan, and the prospectus skills request `org`. `topic` is optional and only ranks the exemplars: its terms sort the corpus by how many they match, and a topic matching nothing still returns the most recent writing. Describe the subject as naturally as you like, because no phrasing can cost you exemplars. It returns:
119
119
 
120
120
  ```yaml
121
121
  styleCard: |
@@ -209,7 +209,9 @@ A value naming no `:AdminUser` on the account is refused, and nothing is written
209
209
 
210
210
  The tools resolve identity from the spawn environment on their own. Pass `author:` only to attribute content to a different operator on a multi-operator account, and only with that operator's real `:AdminUser` id.
211
211
 
212
- If a tool refuses, the id is wrong. Do not retry with a different guess. The account's real admin identity is the operator's to supply.
212
+ If a tool refuses *on identity*, the id is wrong. Do not retry with a different guess. The account's real admin identity is the operator's to supply.
213
+
214
+ Not every refusal is an identity refusal. `voice-distil-profile` also refuses when the profile merged but no source content could be linked to it, either because no sampled item carried readable text or because the sampled items stopped resolving mid-run. Those messages say so, and close with "This is a content condition, not an identity one." Changing the id will not fix them, and the `[voice-identity] refuse` line is absent when they fire.
213
215
 
214
216
  `__org__` is exempt, because the house voice anchors on the business rather than on a person.
215
217
 
@@ -222,6 +224,7 @@ If a tool refuses, the id is wrong. Do not retry with a different guess. The acc
222
224
  | `[voice-distil] scope=<scope> userId=<id\|__org__> format=<format> skip reason=<below-threshold\|recent\|empty-corpus>` | When the cadence/empty guard fires. |
223
225
  | `[voice-distil] scope=<scope> mode=amend userId=<id\|__org__> format=<format> nodeIds=<count> existingProfile=<elementId\|none> eligible=<n> ineligible=<n> exemplars=<n>` | Each amend-mode call. `eligible=0` means surface `ineligible[]` to the operator, never silently no-op. |
224
226
  | `[voice-distil] scope=<scope> mode=write userId=<id\|__org__> anchor=<…> format=<format> amended=true amendedFromNodeIds=<count> ...` | Amend-write fired (operator-initiated update persisted). |
227
+ | `[voice-distil] scope=<scope> userId=<id\|__org__> anchor=<…> format=<format> mode=write sources=none attempted=<n> resolved=0 ...` | The profile merged and the style card saved, but no `LEARNED_FROM` edge was written. `attempted=0` means no sampled item carried readable text; `attempted>0` means every sampled item stopped resolving between the walk and the write. Either way the call throws, and the cause is content, not identity. |
225
228
  | `[voice-retrieve] scope=<scope> format=<format> status=<ok\|fallback-org\|no-data\|error> styleCardBytes=<n> exemplarCount=<k> tokenBudget=<n>` | Each retrieval. `status=fallback-org` proves the personal→org fallback fired. |
226
229
  | `[voice-record-feedback] scope=<scope> userId=<id\|__org__> format=<format> intent="<haiku-summary>" diffBytes=<n>` | Each feedback write. |
227
230
  | `[voice-ingest-session-text] sessionId=<id> adminUser=<id> turns=<n> bytes=<n> skipped=<m>` | On-demand session-text capture. |
@@ -229,7 +232,7 @@ If a tool refuses, the id is wrong. Do not retry with a different guess. The acc
229
232
 
230
233
  **Confirms working:** `[voice-retrieve] exemplarCount≥1` precedes every drafting-skill prompt assembly when the calling skill has not opted out. `[voice-distil]` appears at least once per 30-day window per active format per operator. `[voice-ingest-session-text] turns≥1` appears when the operator asks to capture a session's voice.
231
234
 
232
- **Indicates failure:** `[voice-retrieve] exemplarCount=0` with non-empty corpus → similarity index broken; `[voice-distil] error …` → corpus walk or LLM call failed; absence of `[voice-record-feedback]` after an operator-edit-then-send flow → feedback loop is dead.
235
+ **Indicates failure:** `[voice-retrieve] exemplarCount=0` with non-empty corpus → similarity index broken; `[voice-distil] error …` → corpus walk or LLM call failed; `[voice-distil] … sources=none attempted=0` → the corpus for that format is tagged but holds no readable text, so the profile has a style card and no sources; absence of `[voice-record-feedback]` after an operator-edit-then-send flow → feedback loop is dead.
233
236
 
234
237
  **Diagnostic path:**
235
238
 
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bash
2
+ # Tests for logs-rotate.sh (Task 1706). Fixture-driven: builds a throwaway logs
3
+ # dir per case, runs the real script against it, asserts on the resulting files.
4
+ # No stubs — gzip/find/wc/date are the real binaries, because the portability of
5
+ # exactly those calls across Linux and darwin is part of what this asserts.
6
+ set -uo pipefail
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ ROTATE="$SCRIPT_DIR/../logs-rotate.sh"
10
+
11
+ PASS=0
12
+ FAIL=0
13
+
14
+ run_case() {
15
+ local name="$1"; shift
16
+ echo ""
17
+ echo "=== CASE: $name ==="
18
+ if "$@"; then
19
+ PASS=$((PASS + 1))
20
+ echo "PASS: $name"
21
+ else
22
+ FAIL=$((FAIL + 1))
23
+ echo "FAIL: $name"
24
+ fi
25
+ }
26
+
27
+ # Build a throwaway logs dir. Echoes its path.
28
+ mk_logs_dir() {
29
+ local d
30
+ d="$(mktemp -d "${TMPDIR:-/tmp}/logs-rotate-test.XXXXXX")"
31
+ echo "$d"
32
+ }
33
+
34
+ # Write a file of exactly $2 bytes at $1. $2 must be a whole number of MiB.
35
+ # bs=1048576 rather than bs=1m/1M: the suffix spellings differ between BSD and
36
+ # GNU dd, and this suite runs on both.
37
+ mk_file_of_size() {
38
+ local path="$1" bytes="$2" mib
39
+ mib=$(( bytes / 1048576 ))
40
+ dd if=/dev/zero bs=1048576 count="$mib" 2>/dev/null | tr '\0' 'x' > "$path"
41
+ }
42
+
43
+ size_of() { wc -c < "$1" 2>/dev/null | awk '{print $1+0}'; }
44
+
45
+ # Backdate a path by $2 days. `date -r` is BSD, `date -d @` is GNU.
46
+ backdate() {
47
+ local path="$1" days="$2" secs
48
+ secs=$(( $(date +%s) - days * 86400 ))
49
+ touch -t "$(date -u -r "$secs" +%Y%m%d%H%M 2>/dev/null || date -u -d "@$secs" +%Y%m%d%H%M)" "$path"
50
+ }
51
+
52
+ case_under_threshold_is_noop() {
53
+ local d; d="$(mk_logs_dir)"
54
+ # 63 MiB — just under the 64 MiB threshold, so this also pins the boundary.
55
+ mk_file_of_size "$d/server.log" $((63 * 1024 * 1024))
56
+ "$ROTATE" "$d" >/dev/null 2>&1
57
+ local rc=$?
58
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; rm -rf "$d"; return 1; }
59
+ [ ! -e "$d/server.log.1.gz" ] || { echo " rotated a 63 MiB file"; rm -rf "$d"; return 1; }
60
+ [ "$(size_of "$d/server.log")" -eq $((63 * 1024 * 1024)) ] \
61
+ || { echo " file was modified"; rm -rf "$d"; return 1; }
62
+ rm -rf "$d"; return 0
63
+ }
64
+
65
+ case_over_threshold_rotates_and_truncates() {
66
+ local d; d="$(mk_logs_dir)"
67
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
68
+ "$ROTATE" "$d" >/dev/null 2>&1
69
+ [ -f "$d/server.log.1.gz" ] || { echo " no .1.gz produced"; rm -rf "$d"; return 1; }
70
+ # The live file must still exist (readers hardcode the path) and be small:
71
+ # it holds only the marker line.
72
+ [ -f "$d/server.log" ] || { echo " live path missing after rotate"; rm -rf "$d"; return 1; }
73
+ local live; live="$(size_of "$d/server.log")"
74
+ [ "$live" -lt 4096 ] || { echo " live file not truncated: $live bytes"; rm -rf "$d"; return 1; }
75
+ rm -rf "$d"; return 0
76
+ }
77
+
78
+ case_marker_line_present() {
79
+ local d; d="$(mk_logs_dir)"
80
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
81
+ "$ROTATE" "$d" >/dev/null 2>&1
82
+ grep -q '\[logs-rotate\] rotated file=server.log prev-bytes=68157440 prev-file=server.log.1.gz keep=5' "$d/server.log" \
83
+ || { echo " marker line absent or malformed:"; cat "$d/server.log"; rm -rf "$d"; return 1; }
84
+ # The marker must be datable from itself.
85
+ grep -qE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z \[logs-rotate\]' "$d/server.log" \
86
+ || { echo " marker line not timestamped"; rm -rf "$d"; return 1; }
87
+ rm -rf "$d"; return 0
88
+ }
89
+
90
+ case_generation_shift_drops_the_sixth() {
91
+ local d; d="$(mk_logs_dir)"
92
+ # Seed 5 existing generations, each identifiable by content.
93
+ local i
94
+ for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
95
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
96
+ "$ROTATE" "$d" >/dev/null 2>&1
97
+ # .5.gz must now hold what .4.gz held; the old .5.gz is gone.
98
+ [ "$(gzip -cd "$d/server.log.5.gz")" = "gen4" ] || { echo " .5.gz is not the old .4.gz"; rm -rf "$d"; return 1; }
99
+ [ "$(gzip -cd "$d/server.log.2.gz")" = "gen1" ] || { echo " .2.gz is not the old .1.gz"; rm -rf "$d"; return 1; }
100
+ [ ! -e "$d/server.log.6.gz" ] || { echo " a 6th generation was created"; rm -rf "$d"; return 1; }
101
+ rm -rf "$d"; return 0
102
+ }
103
+
104
+ case_lock_held_is_noop_exit_zero() {
105
+ local d; d="$(mk_logs_dir)"
106
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
107
+ mkdir "$d/.rotate.lock" # fresh lock, not stale
108
+ "$ROTATE" "$d" >/dev/null 2>&1
109
+ local rc=$?
110
+ [ "$rc" -eq 0 ] || { echo " expected exit 0 when lock held, got $rc"; rm -rf "$d"; return 1; }
111
+ [ ! -e "$d/server.log.1.gz" ] || { echo " rotated despite held lock"; rm -rf "$d"; return 1; }
112
+ # A held lock must survive the tick that could not take it.
113
+ [ -d "$d/.rotate.lock" ] || { echo " another holder's lock was removed"; rm -rf "$d"; return 1; }
114
+ rm -rf "$d"; return 0
115
+ }
116
+
117
+ case_stale_lock_is_reclaimed() {
118
+ local d; d="$(mk_logs_dir)"
119
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
120
+ mkdir "$d/.rotate.lock"
121
+ backdate "$d/.rotate.lock" 1 # 1 day > STALE_LOCK_MINUTES=30
122
+ "$ROTATE" "$d" >/dev/null 2>&1
123
+ [ -f "$d/server.log.1.gz" ] || { echo " stale lock was not reclaimed"; rm -rf "$d"; return 1; }
124
+ rm -rf "$d"; return 0
125
+ }
126
+
127
+ case_missing_logs_dir_is_noop_exit_zero() {
128
+ "$ROTATE" "/nonexistent/logs/dir/for/test" >/dev/null 2>&1
129
+ local rc=$?
130
+ [ "$rc" -eq 0 ] || { echo " expected exit 0 on missing dir, got $rc"; return 1; }
131
+ return 0
132
+ }
133
+
134
+ case_lock_released_on_exit() {
135
+ local d; d="$(mk_logs_dir)"
136
+ mk_file_of_size "$d/server.log" $((1 * 1024 * 1024))
137
+ "$ROTATE" "$d" >/dev/null 2>&1
138
+ [ ! -e "$d/.rotate.lock" ] || { echo " lock not released"; rm -rf "$d"; return 1; }
139
+ rm -rf "$d"; return 0
140
+ }
141
+
142
+ case_purges_rc_child_older_than_seven_days() {
143
+ local d; d="$(mk_logs_dir)"
144
+ echo old > "$d/rc-child-aaaa.log"; backdate "$d/rc-child-aaaa.log" 9
145
+ echo new > "$d/rc-child-bbbb.log" # today
146
+ "$ROTATE" "$d" >/dev/null 2>&1
147
+ [ ! -e "$d/rc-child-aaaa.log" ] || { echo " 9-day-old rc-child survived"; rm -rf "$d"; return 1; }
148
+ [ -f "$d/rc-child-bbbb.log" ] || { echo " today's rc-child was purged"; rm -rf "$d"; return 1; }
149
+ rm -rf "$d"; return 0
150
+ }
151
+
152
+ case_purge_does_not_touch_server_log() {
153
+ local d; d="$(mk_logs_dir)"
154
+ echo old > "$d/server.log"; backdate "$d/server.log" 90
155
+ echo x | gzip -c > "$d/server.log.1.gz"; backdate "$d/server.log.1.gz" 90
156
+ "$ROTATE" "$d" >/dev/null 2>&1
157
+ [ -f "$d/server.log" ] || { echo " server.log was purged by age"; rm -rf "$d"; return 1; }
158
+ [ -f "$d/server.log.1.gz" ] || { echo " a kept generation was purged by age"; rm -rf "$d"; return 1; }
159
+ rm -rf "$d"; return 0
160
+ }
161
+
162
+ case_mcp_stderr_live_file_size_rotates() {
163
+ local d; d="$(mk_logs_dir)"
164
+ mk_file_of_size "$d/mcp-memory-stderr-2026-07-17.log" $((65 * 1024 * 1024))
165
+ "$ROTATE" "$d" >/dev/null 2>&1
166
+ [ -f "$d/mcp-memory-stderr-2026-07-17.log.1.gz" ] \
167
+ || { echo " live mcp stderr log was not size-rotated"; rm -rf "$d"; return 1; }
168
+ rm -rf "$d"; return 0
169
+ }
170
+
171
+ # --- Code-review regressions (2026-07-17) -----------------------------------
172
+
173
+ # A failing gzip must not cost a generation. The shift used to run BEFORE the
174
+ # gzip, so every failure moved .4->.5 ... .1->.2 and wrote nothing to .1 —
175
+ # leaving a hole and dropping the oldest. The trigger is ENOSPC, i.e. exactly
176
+ # the condition rotation exists to prevent: 5 failing ticks destroyed 4 of the
177
+ # 5 retained generations while never truncating the live file.
178
+ case_gzip_failure_preserves_generations() {
179
+ local d; d="$(mk_logs_dir)"
180
+ local bin="$d/../bin-$$"; mkdir -p "$bin"
181
+ printf '#!/bin/sh\nexit 1\n' > "$bin/gzip"; chmod +x "$bin/gzip"
182
+ local i
183
+ for i in 1 2 3 4 5; do echo "gen$i" | gzip -c > "$d/server.log.$i.gz"; done
184
+ mk_file_of_size "$d/server.log" $((65 * 1024 * 1024))
185
+
186
+ PATH="$bin:$PATH" "$ROTATE" "$d" >/dev/null 2>&1
187
+ local rc=$?
188
+
189
+ local fail=0
190
+ for i in 1 2 3 4 5; do
191
+ if [ "$(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)" != "gen$i" ]; then
192
+ echo " .$i.gz should still be gen$i, got: $(gzip -cd "$d/server.log.$i.gz" 2>/dev/null)"; fail=1
193
+ fi
194
+ done
195
+ # The live file must be untouched — a failed rotate never truncates.
196
+ [ "$(size_of "$d/server.log")" -eq $((65 * 1024 * 1024)) ] \
197
+ || { echo " live file was truncated despite gzip failure"; fail=1; }
198
+ [ "$rc" -eq 0 ] || { echo " expected exit 0, got $rc"; fail=1; }
199
+ [ ! -e "$d/server.log.1.gz.tmp" ] || { echo " temp file left behind"; fail=1; }
200
+ rm -rf "$d" "$bin"; return $fail
201
+ }
202
+
203
+ # The mcp stderr tee opens its file at process start and holds the stream for the
204
+ # process's whole life, writing ONLY when the server emits stderr — which a
205
+ # healthy server does not do for days. So a live file's mtime DOES go stale, and
206
+ # deleting it sends every later stderr write to an unlinked inode: invisible
207
+ # exactly when the fault you are chasing finally appears. Never purge these.
208
+ case_never_purges_mcp_stderr_by_age() {
209
+ local d; d="$(mk_logs_dir)"
210
+ echo quiet > "$d/mcp-memory-stderr-2026-01-01.log"
211
+ backdate "$d/mcp-memory-stderr-2026-01-01.log" 90
212
+ echo x | gzip -c > "$d/mcp-memory-stderr-2026-01-01.log.1.gz"
213
+ backdate "$d/mcp-memory-stderr-2026-01-01.log.1.gz" 90
214
+ "$ROTATE" "$d" >/dev/null 2>&1
215
+ [ -f "$d/mcp-memory-stderr-2026-01-01.log" ] \
216
+ || { echo " a 90-day-stale mcp stderr log was purged — it may still be held open"; rm -rf "$d"; return 1; }
217
+ [ -f "$d/mcp-memory-stderr-2026-01-01.log.1.gz" ] \
218
+ || { echo " an mcp generation was purged by age"; rm -rf "$d"; return 1; }
219
+ rm -rf "$d"; return 0
220
+ }
221
+
222
+ # rc-child's 16 MiB cap does NOT hold: `written` is in-memory per manager
223
+ # process, so a manager restart resumes appending to the same path. Observed at
224
+ # 54 MB on sitedesk-code (3.25x the cap). A live long-lived session's file has a
225
+ # fresh mtime forever, so age-purge never reaches it — it needs size rotation.
226
+ case_rc_child_size_rotates() {
227
+ local d; d="$(mk_logs_dir)"
228
+ mk_file_of_size "$d/rc-child-live-session.log" $((65 * 1024 * 1024))
229
+ "$ROTATE" "$d" >/dev/null 2>&1
230
+ [ -f "$d/rc-child-live-session.log.1.gz" ] \
231
+ || { echo " an oversized rc-child log was not size-rotated"; rm -rf "$d"; return 1; }
232
+ local live; live="$(size_of "$d/rc-child-live-session.log")"
233
+ [ "$live" -lt 4096 ] || { echo " rc-child live file not truncated: $live"; rm -rf "$d"; return 1; }
234
+ rm -rf "$d"; return 0
235
+ }
236
+
237
+ run_case "gzip failure preserves generations" case_gzip_failure_preserves_generations
238
+ run_case "never purges mcp stderr by age" case_never_purges_mcp_stderr_by_age
239
+ run_case "rc-child size-rotates" case_rc_child_size_rotates
240
+
241
+ run_case "under threshold is a no-op" case_under_threshold_is_noop
242
+ run_case "over threshold rotates and truncates" case_over_threshold_rotates_and_truncates
243
+ run_case "marker line present and timestamped" case_marker_line_present
244
+ run_case "generation shift drops the sixth" case_generation_shift_drops_the_sixth
245
+ run_case "lock held is a no-op, exit 0" case_lock_held_is_noop_exit_zero
246
+ run_case "stale lock is reclaimed" case_stale_lock_is_reclaimed
247
+ run_case "missing logs dir is a no-op, exit 0" case_missing_logs_dir_is_noop_exit_zero
248
+ run_case "lock released on exit" case_lock_released_on_exit
249
+ run_case "purges rc-child older than 7 days" case_purges_rc_child_older_than_seven_days
250
+ run_case "purge does not touch server.log" case_purge_does_not_touch_server_log
251
+ run_case "mcp stderr live file size-rotates" case_mcp_stderr_live_file_size_rotates
252
+
253
+ echo ""
254
+ echo "===================="
255
+ echo "PASS: $PASS FAIL: $FAIL"
256
+ [ "$FAIL" -eq 0 ]