session-orchestrator 3.16.0 → 3.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +17 -1
- package/CHANGELOG.md +130 -412
- package/README.md +17 -12
- package/SECURITY.md +190 -27
- package/agents/AGENTS.md +20 -3
- package/agents/code-implementer.md +6 -6
- package/agents/db-specialist.md +1 -1
- package/agents/qa-strategist.md +31 -6
- package/agents/schemas/qa-strategist.schema.json +27 -0
- package/agents/schemas/test-writer.schema.json +60 -2
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +1 -1
- package/agents/test-writer.md +29 -10
- package/agents/ui-developer.md +1 -1
- package/commands/contract-version-bump.md +28 -0
- package/commands/portfolio.md +1 -1
- package/docs/README.md +2 -1
- package/docs/USER-GUIDE.md +8 -3
- package/docs/ci-setup.md +121 -7
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +22 -9
- package/docs/events-schema.md +5 -1
- package/docs/instruction-delivery.md +444 -0
- package/docs/pi-setup.md +1 -1
- package/docs/rule-authoring.md +58 -9
- package/docs/session-config-reference.md +306 -6
- package/docs/session-config-template.md +66 -3
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/_lib/guard-source-loader.mjs +467 -0
- package/hooks/_lib/lock-bootstrap.mjs +21 -0
- package/hooks/_lib/vcs-create-matcher.mjs +119 -0
- package/hooks/config-protection.mjs +0 -0
- package/hooks/enforce-commands.mjs +10 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +11 -2
- package/hooks/hooks-pi.json +10 -0
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +178 -18
- package/hooks/on-session-start.mjs +23 -0
- package/hooks/post-bash-write-verify.mjs +977 -0
- package/hooks/post-subagent-discovery-validator.mjs +256 -41
- package/hooks/pre-bash-destructive-guard.mjs +525 -160
- package/hooks/pre-bash-issue-budget.mjs +167 -0
- package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
- package/hooks/pre-bash-templates-first.mjs +96 -63
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/hooks/subagent-telemetry.mjs +527 -37
- package/package.json +16 -3
- package/pi/prompts/contract-version-bump.md +12 -0
- package/rules/README.md +32 -0
- package/scripts/archive-closed-prds.mjs +12 -22
- package/scripts/autopilot-multi.mjs +103 -20
- package/scripts/backfill-abandoned-sessions.mjs +160 -4
- package/scripts/check-doc-consistency.sh +17 -1
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/eval-session.mjs +50 -9
- package/scripts/fleet-instruction-scan.mjs +141 -0
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/autopilot/mr-draft.mjs +31 -1
- package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
- package/scripts/lib/backlog-scan.mjs +39 -6
- package/scripts/lib/blocked-commands-policy.mjs +340 -0
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/ci-status-banner.mjs +75 -12
- package/scripts/lib/claude-md-budget-lint.mjs +283 -34
- package/scripts/lib/command-blocker.mjs +1013 -58
- package/scripts/lib/config/config-protection.mjs +2 -1
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/drift-check.mjs +9 -1
- package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
- package/scripts/lib/config/issue-budget.mjs +123 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/reconcile.mjs +21 -0
- package/scripts/lib/config/section-extractor.mjs +121 -1
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config-schema.mjs +23 -3
- package/scripts/lib/config.mjs +32 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/convergence-monitor.mjs +49 -3
- package/scripts/lib/description-surface.mjs +535 -0
- package/scripts/lib/dispatcher/enumerate.mjs +136 -30
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
- package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
- package/scripts/lib/eval/engine.mjs +47 -5
- package/scripts/lib/events.mjs +59 -7
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/gates/gate-full.mjs +15 -3
- package/scripts/lib/gates/gate-helpers.mjs +132 -6
- package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
- package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
- package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
- package/scripts/lib/handover-gate.mjs +7 -3
- package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
- package/scripts/lib/instruction-budget-guard.mjs +402 -51
- package/scripts/lib/io.mjs +345 -10
- package/scripts/lib/issue-budget.mjs +269 -0
- package/scripts/lib/issue-close-strip-labels.mjs +39 -9
- package/scripts/lib/label-scope.mjs +47 -0
- package/scripts/lib/learnings/schema.mjs +43 -3
- package/scripts/lib/lock-reaper.mjs +1 -2
- package/scripts/lib/memory-proposals/schema.mjs +36 -1
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/peer-discovery.mjs +645 -0
- package/scripts/lib/pi-hook-bridge.mjs +146 -17
- package/scripts/lib/product-repo-detect.mjs +9 -8
- package/scripts/lib/project-hygiene.mjs +432 -0
- package/scripts/lib/quality-gate.mjs +167 -0
- package/scripts/lib/recommendations-v0.mjs +1 -1
- package/scripts/lib/reconcile/eligibility.mjs +1 -1
- package/scripts/lib/reconcile/emitter.mjs +23 -4
- package/scripts/lib/reconcile/engine.mjs +147 -39
- package/scripts/lib/reconcile/idempotency.mjs +114 -14
- package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
- package/scripts/lib/resource-probe/evaluate.mjs +70 -4
- package/scripts/lib/resource-probe.mjs +19 -0
- package/scripts/lib/rule-loader.mjs +6 -0
- package/scripts/lib/scope-baseline.mjs +564 -0
- package/scripts/lib/scope-gate.mjs +399 -98
- package/scripts/lib/session-close-backfill.mjs +61 -6
- package/scripts/lib/session-end/phase-skip.mjs +1 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-id.mjs +221 -41
- package/scripts/lib/session-lock.mjs +304 -6
- package/scripts/lib/session-schema/constants.mjs +22 -3
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema/validator.mjs +16 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/sessions-integrity-banner.mjs +294 -0
- package/scripts/lib/sessions-staleness-banner.mjs +121 -12
- package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
- package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/spiral-carryover.mjs +142 -30
- package/scripts/lib/state-md/mission-status.mjs +53 -3
- package/scripts/lib/subagents-schema.mjs +43 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
- package/scripts/lib/tests-src-ratio.mjs +484 -0
- package/scripts/lib/validate/check-agents.mjs +56 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
- package/scripts/lib/validate/check-rules.mjs +217 -35
- package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
- package/scripts/lib/validate-vendored-rules.mjs +10 -2
- package/scripts/lib/vault-archive.mjs +17 -2
- package/scripts/lib/vault-backfill/glab.mjs +8 -0
- package/scripts/lib/vault-mirror/process.mjs +30 -0
- package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/lib/vcs-repo-spec.mjs +362 -0
- package/scripts/lib/wave-resource-gate.mjs +115 -11
- package/scripts/lib/worktree/listing.mjs +44 -7
- package/scripts/mcp-server.sh +32 -6
- package/scripts/measure-context-overhead.sh +151 -0
- package/scripts/memory-propose.mjs +72 -9
- package/scripts/print-applicable-rules.mjs +51 -12
- package/scripts/release.mjs +534 -0
- package/scripts/run-quality-gate.mjs +123 -5
- package/scripts/telemetry.mjs +250 -0
- package/scripts/validate-wave-scope.mjs +182 -17
- package/scripts/vault-integration-watcher.mjs +32 -10
- package/skills/_shared/config-reading.md +2 -2
- package/skills/bootstrap/fast-template.md +1 -1
- package/skills/claude-md-drift-check/checker.mjs +145 -28
- package/skills/contract-version-bump/SKILL.md +219 -0
- package/skills/discovery/SKILL.md +4 -4
- package/skills/discovery/issue-templates.md +11 -11
- package/skills/discovery/probes-audit.md +1 -1
- package/skills/discovery/probes-feature.md +1 -1
- package/skills/discovery/probes-session.md +26 -5
- package/skills/ecosystem-health/SKILL.md +1 -1
- package/skills/ecosystem-health/wizard.md +4 -4
- package/skills/evolve/SKILL.md +1 -0
- package/skills/gitlab-ops/SKILL.md +20 -12
- package/skills/gitlab-portfolio/SKILL.md +2 -2
- package/skills/hook-development/SKILL.md +1 -1
- package/skills/mode-selector/SKILL.md +1 -1
- package/skills/npm-publish/SKILL.md +97 -0
- package/skills/plan/SKILL.md +5 -5
- package/skills/plan/mode-feature.md +4 -4
- package/skills/plan/mode-new.md +10 -10
- package/skills/plan/mode-retro.md +1 -1
- package/skills/quality-gates/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +21 -4
- package/skills/session-end/SKILL.md +108 -14
- package/skills/session-end/discovery-scan.md +4 -2
- package/skills/session-end/drift-operations.md +4 -4
- package/skills/session-end/metrics-collection.md +13 -0
- package/skills/session-end/phase-3-2-docs-verification.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +2 -1
- package/skills/session-end/plan-verification.md +5 -2
- package/skills/session-end/vault-operations.md +1 -1
- package/skills/session-end/verification-checklist.md +1 -1
- package/skills/session-plan/SKILL.md +6 -2
- package/skills/session-plan/wave-template.md +2 -0
- package/skills/session-start/SKILL.md +149 -7
- package/skills/session-start/phase-4-5-resource-health.md +15 -2
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +228 -40
- package/skills/wave-executor/SKILL.md +5 -2
- package/skills/wave-executor/circuit-breaker.md +2 -0
- package/skills/wave-executor/wave-loop.md +163 -10
- package/templates/_shared/loop.md +4 -4
|
@@ -23,10 +23,21 @@
|
|
|
23
23
|
*
|
|
24
24
|
* This module intentionally does NOT reuse `scripts/lib/vault-backfill/glab.mjs`
|
|
25
25
|
* — that helper is vault-specific and wider in scope than needed here.
|
|
26
|
+
*
|
|
27
|
+
* Host pinning (#839): every `glab`/`gh` spawn below (the dedup lookup AND the
|
|
28
|
+
* `issue create` write) is pinned to the resolved repo via `-R <spec>`. A bare
|
|
29
|
+
* spawn falls back to the ambient `GITLAB_HOST`/`GH_HOST`, which can silently
|
|
30
|
+
* resolve to the WRONG GitLab instance on a multi-host machine — for a WRITE
|
|
31
|
+
* path (`issue create`) that means either filing into the wrong project, or
|
|
32
|
+
* failing open and defeating `findExistingLabeledIssue`'s dedup (which itself
|
|
33
|
+
* fails open to `{exists:false}` on any CLI error), risking double-filed
|
|
34
|
+
* issues. See `scripts/lib/vcs-repo-spec.mjs` for the full rationale.
|
|
26
35
|
*/
|
|
27
36
|
|
|
28
37
|
import { execFileSync } from 'node:child_process';
|
|
29
38
|
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
39
|
+
import { chargeIssueBudget, formatBlockReason } from './issue-budget.mjs';
|
|
40
|
+
import { resolveRepoSpec } from './vcs-repo-spec.mjs';
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
43
|
* Compute a stable 8-char sha256 hash of a task description.
|
|
@@ -51,13 +62,57 @@ function truncate(s, max) {
|
|
|
51
62
|
return `${str.slice(0, Math.max(0, max - 1))}…`;
|
|
52
63
|
}
|
|
53
64
|
|
|
65
|
+
/**
|
|
66
|
+
* True when the argv describes an issue-CREATE call (not a list/search).
|
|
67
|
+
* The dedup lookups in this module also go through `runCli`, and those must
|
|
68
|
+
* never be charged against the issue budget.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} cmd
|
|
71
|
+
* @param {string[]} args
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
function isIssueCreateArgv(cmd, args) {
|
|
75
|
+
if (cmd !== 'gh' && cmd !== 'glab') return false;
|
|
76
|
+
if (!Array.isArray(args) || args.length < 2) return false;
|
|
77
|
+
return args[0] === 'issue' && (args[1] === 'create' || args[1] === 'new');
|
|
78
|
+
}
|
|
79
|
+
|
|
54
80
|
/**
|
|
55
81
|
* Run a CLI command and return { ok, stdout, stderr }. Never throws.
|
|
82
|
+
*
|
|
83
|
+
* ISSUE-BUDGET GATE (both Node producers funnel through here): before shelling
|
|
84
|
+
* out to an issue-create call, the same `chargeIssueBudget` decision the
|
|
85
|
+
* `pre-bash-issue-budget` hook applies is evaluated here — otherwise the
|
|
86
|
+
* programmatic path would be a hole straight through the shell-level cap.
|
|
87
|
+
*
|
|
88
|
+
* In practice BOTH current callers are exempt by class (`createSpiralCarryoverIssue`
|
|
89
|
+
* emits `[Carryover] [SPIRAL|FAILED] …`, `createBrokenWindowIssue` emits the
|
|
90
|
+
* `broken-window` label), so this gate is a no-op for them by design — that is
|
|
91
|
+
* exactly the session-end promise at SKILL.md:319 / :1113 being preserved. It
|
|
92
|
+
* bites for any FUTURE non-exempt producer added to this module.
|
|
93
|
+
*
|
|
56
94
|
* @param {string} cmd
|
|
57
95
|
* @param {string[]} args
|
|
58
|
-
* @returns {{ ok: boolean, stdout: string, stderr: string }}
|
|
96
|
+
* @returns {{ ok: boolean, stdout: string, stderr: string, budgetBlocked?: boolean }}
|
|
59
97
|
*/
|
|
60
98
|
function runCli(cmd, args) {
|
|
99
|
+
if (isIssueCreateArgv(cmd, args)) {
|
|
100
|
+
try {
|
|
101
|
+
const repoRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
102
|
+
const titleIdx = args.indexOf('--title');
|
|
103
|
+
const verdict = chargeIssueBudget({
|
|
104
|
+
repoRoot,
|
|
105
|
+
sessionId: process.env.CLAUDE_SESSION_ID || null,
|
|
106
|
+
command: [cmd, ...args].join(' '),
|
|
107
|
+
title: titleIdx >= 0 ? (args[titleIdx + 1] ?? null) : null,
|
|
108
|
+
});
|
|
109
|
+
if (verdict.decision === 'block') {
|
|
110
|
+
return { ok: false, stdout: '', stderr: formatBlockReason(verdict), budgetBlocked: true };
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// Fail open — a budget-bookkeeping failure must never lose a carryover.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
61
116
|
try {
|
|
62
117
|
const stdout = execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
118
|
return { ok: true, stdout: String(stdout ?? ''), stderr: '' };
|
|
@@ -96,18 +151,33 @@ function parseIssueCreateOutput(stdout) {
|
|
|
96
151
|
* Returns `{ exists: false }` on any CLI failure — caller treats this as
|
|
97
152
|
* "probably no duplicate" and proceeds with creation (fail-open).
|
|
98
153
|
*
|
|
99
|
-
* @param {{
|
|
154
|
+
* @param {{
|
|
155
|
+
* taskHash: string,
|
|
156
|
+
* label: string,
|
|
157
|
+
* vcs?: 'gitlab' | 'github',
|
|
158
|
+
* repoRoot?: string,
|
|
159
|
+
* resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
|
|
160
|
+
* }} opts
|
|
100
161
|
* @returns {Promise<{ exists: boolean, issueId?: number, issueUrl?: string }>}
|
|
101
162
|
*/
|
|
102
|
-
async function findExistingLabeledIssue({
|
|
163
|
+
async function findExistingLabeledIssue({
|
|
164
|
+
taskHash,
|
|
165
|
+
label,
|
|
166
|
+
vcs = 'gitlab',
|
|
167
|
+
repoRoot = process.cwd(),
|
|
168
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
169
|
+
} = {}) {
|
|
103
170
|
if (!taskHash || typeof taskHash !== 'string') {
|
|
104
171
|
return { exists: false };
|
|
105
172
|
}
|
|
106
173
|
|
|
174
|
+
const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
|
|
175
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
|
|
176
|
+
|
|
107
177
|
try {
|
|
108
|
-
if (
|
|
178
|
+
if (vcsResolved === 'github') {
|
|
109
179
|
// gh: list open issues carrying the label; body match is done locally.
|
|
110
|
-
const
|
|
180
|
+
const args = [
|
|
111
181
|
'issue',
|
|
112
182
|
'list',
|
|
113
183
|
'--label',
|
|
@@ -118,7 +188,9 @@ async function findExistingLabeledIssue({ taskHash, label, vcs = 'gitlab' } = {}
|
|
|
118
188
|
'100',
|
|
119
189
|
'--json',
|
|
120
190
|
'number,url,body',
|
|
121
|
-
]
|
|
191
|
+
];
|
|
192
|
+
if (spec) args.push('-R', spec);
|
|
193
|
+
const res = runCli('gh', args);
|
|
122
194
|
if (!res.ok) return { exists: false };
|
|
123
195
|
let arr;
|
|
124
196
|
try {
|
|
@@ -136,16 +208,9 @@ async function findExistingLabeledIssue({ taskHash, label, vcs = 'gitlab' } = {}
|
|
|
136
208
|
}
|
|
137
209
|
|
|
138
210
|
// Default: gitlab via glab.
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
'--label',
|
|
143
|
-
label,
|
|
144
|
-
'--per-page',
|
|
145
|
-
'100',
|
|
146
|
-
'--output',
|
|
147
|
-
'json',
|
|
148
|
-
]);
|
|
211
|
+
const args = ['issue', 'list', '--label', label, '--per-page', '100', '--output', 'json'];
|
|
212
|
+
if (spec) args.push('-R', spec);
|
|
213
|
+
const res = runCli('glab', args);
|
|
149
214
|
if (!res.ok) return { exists: false };
|
|
150
215
|
let arr;
|
|
151
216
|
try {
|
|
@@ -173,22 +238,42 @@ async function findExistingLabeledIssue({ taskHash, label, vcs = 'gitlab' } = {}
|
|
|
173
238
|
* Check whether a carryover issue already exists for this task.
|
|
174
239
|
* Thin wrapper over `findExistingLabeledIssue` with the `type:carryover` label.
|
|
175
240
|
*
|
|
176
|
-
* @param {{
|
|
241
|
+
* @param {{
|
|
242
|
+
* taskHash: string,
|
|
243
|
+
* vcs?: 'gitlab' | 'github',
|
|
244
|
+
* repoRoot?: string,
|
|
245
|
+
* resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
|
|
246
|
+
* }} opts
|
|
177
247
|
* @returns {Promise<{ exists: boolean, issueId?: number, issueUrl?: string }>}
|
|
178
248
|
*/
|
|
179
|
-
export async function findExistingCarryover({
|
|
180
|
-
|
|
249
|
+
export async function findExistingCarryover({
|
|
250
|
+
taskHash,
|
|
251
|
+
vcs = 'gitlab',
|
|
252
|
+
repoRoot = process.cwd(),
|
|
253
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
254
|
+
} = {}) {
|
|
255
|
+
return findExistingLabeledIssue({ taskHash, label: 'type:carryover', vcs, repoRoot, resolveRepoSpecFn });
|
|
181
256
|
}
|
|
182
257
|
|
|
183
258
|
/**
|
|
184
259
|
* Check whether a broken-window closure issue already exists for this task.
|
|
185
260
|
* Thin wrapper over `findExistingLabeledIssue` with the `broken-window` label.
|
|
186
261
|
*
|
|
187
|
-
* @param {{
|
|
262
|
+
* @param {{
|
|
263
|
+
* taskHash: string,
|
|
264
|
+
* vcs?: 'gitlab' | 'github',
|
|
265
|
+
* repoRoot?: string,
|
|
266
|
+
* resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
|
|
267
|
+
* }} opts
|
|
188
268
|
* @returns {Promise<{ exists: boolean, issueId?: number, issueUrl?: string }>}
|
|
189
269
|
*/
|
|
190
|
-
export async function findExistingBrokenWindow({
|
|
191
|
-
|
|
270
|
+
export async function findExistingBrokenWindow({
|
|
271
|
+
taskHash,
|
|
272
|
+
vcs = 'gitlab',
|
|
273
|
+
repoRoot = process.cwd(),
|
|
274
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
275
|
+
} = {}) {
|
|
276
|
+
return findExistingLabeledIssue({ taskHash, label: 'broken-window', vcs, repoRoot, resolveRepoSpecFn });
|
|
192
277
|
}
|
|
193
278
|
|
|
194
279
|
/**
|
|
@@ -245,7 +330,9 @@ function buildCarryoverBody({ taskDescription, kind, context, taskHash }) {
|
|
|
245
330
|
* kind: 'SPIRAL' | 'FAILED',
|
|
246
331
|
* context: string,
|
|
247
332
|
* priority?: 'high' | 'medium',
|
|
248
|
-
* vcs?: 'gitlab' | 'github'
|
|
333
|
+
* vcs?: 'gitlab' | 'github',
|
|
334
|
+
* repoRoot?: string,
|
|
335
|
+
* resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined
|
|
249
336
|
* }} opts
|
|
250
337
|
* @returns {Promise<{
|
|
251
338
|
* created: boolean,
|
|
@@ -261,6 +348,8 @@ export async function createSpiralCarryoverIssue({
|
|
|
261
348
|
context,
|
|
262
349
|
priority = 'high',
|
|
263
350
|
vcs = 'gitlab',
|
|
351
|
+
repoRoot = process.cwd(),
|
|
352
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
264
353
|
} = {}) {
|
|
265
354
|
try {
|
|
266
355
|
if (kind !== 'SPIRAL' && kind !== 'FAILED') {
|
|
@@ -272,10 +361,19 @@ export async function createSpiralCarryoverIssue({
|
|
|
272
361
|
}
|
|
273
362
|
const vcsResolved = vcs === 'github' ? 'github' : 'gitlab';
|
|
274
363
|
|
|
364
|
+
// Resolve the -R/--repo host-pinning spec ONCE (#839); reuse the same
|
|
365
|
+
// resolved value for the dedup lookup below instead of re-resolving.
|
|
366
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
|
|
367
|
+
|
|
275
368
|
const taskHash = computeTaskHash(taskDescription);
|
|
276
369
|
|
|
277
370
|
// Dedup check first.
|
|
278
|
-
const existing = await findExistingCarryover({
|
|
371
|
+
const existing = await findExistingCarryover({
|
|
372
|
+
taskHash,
|
|
373
|
+
vcs: vcsResolved,
|
|
374
|
+
repoRoot,
|
|
375
|
+
resolveRepoSpecFn: () => spec,
|
|
376
|
+
});
|
|
279
377
|
if (existing.exists) {
|
|
280
378
|
return {
|
|
281
379
|
created: false,
|
|
@@ -288,7 +386,7 @@ export async function createSpiralCarryoverIssue({
|
|
|
288
386
|
const truncatedDesc = truncate(String(taskDescription ?? '').trim() || '(untitled task)', 80);
|
|
289
387
|
const title = `[Carryover] [${kind}] ${truncatedDesc}`;
|
|
290
388
|
const body = buildCarryoverBody({ taskDescription, kind, context, taskHash });
|
|
291
|
-
const labels = `priority
|
|
389
|
+
const labels = `priority::${priority},status:ready,type:carryover`;
|
|
292
390
|
|
|
293
391
|
let cmd;
|
|
294
392
|
let args;
|
|
@@ -299,6 +397,7 @@ export async function createSpiralCarryoverIssue({
|
|
|
299
397
|
cmd = 'glab';
|
|
300
398
|
args = ['issue', 'create', '--title', title, '--description', body, '--label', labels];
|
|
301
399
|
}
|
|
400
|
+
if (spec) args.push('-R', spec);
|
|
302
401
|
|
|
303
402
|
const res = runCli(cmd, args);
|
|
304
403
|
if (!res.ok) {
|
|
@@ -403,13 +502,15 @@ function buildBrokenWindowBody({ item, taskHash, dueDate, vcs }) {
|
|
|
403
502
|
* 5. Parse stdout for the issue URL and return `{ created: true, issueId, issueUrl, due }`.
|
|
404
503
|
*
|
|
405
504
|
* Never throws. On any CLI failure returns `{ created: false, skipped: 'error', error }`.
|
|
406
|
-
* `repoRoot`
|
|
407
|
-
*
|
|
505
|
+
* `repoRoot` defaults to `process.cwd()` and is used (#839) to resolve the
|
|
506
|
+
* `-R`/`--repo` host-pinning spec via `resolveRepoSpecFn` — previously accepted
|
|
507
|
+
* only for signature symmetry and left unused.
|
|
408
508
|
*
|
|
409
509
|
* @param {{
|
|
410
510
|
* item: { title?: string, source?: string, description?: string, sessionId?: string },
|
|
411
511
|
* dueDays?: number,
|
|
412
512
|
* repoRoot?: string,
|
|
513
|
+
* resolveRepoSpecFn?: (opts: { repoRoot: string, vcs: 'gitlab' | 'github' }) => string | undefined,
|
|
413
514
|
* vcs?: 'gitlab' | 'github'
|
|
414
515
|
* }} opts
|
|
415
516
|
* @returns {Promise<{
|
|
@@ -424,7 +525,8 @@ function buildBrokenWindowBody({ item, taskHash, dueDate, vcs }) {
|
|
|
424
525
|
export async function createBrokenWindowIssue({
|
|
425
526
|
item,
|
|
426
527
|
dueDays = 7,
|
|
427
|
-
repoRoot
|
|
528
|
+
repoRoot = process.cwd(),
|
|
529
|
+
resolveRepoSpecFn = resolveRepoSpec,
|
|
428
530
|
vcs = 'gitlab',
|
|
429
531
|
} = {}) {
|
|
430
532
|
try {
|
|
@@ -437,11 +539,20 @@ export async function createBrokenWindowIssue({
|
|
|
437
539
|
|
|
438
540
|
const dueDate = computeDueDate(dueDays);
|
|
439
541
|
|
|
542
|
+
// Resolve the -R/--repo host-pinning spec ONCE (#839); reuse the same
|
|
543
|
+
// resolved value for the dedup lookup below instead of re-resolving.
|
|
544
|
+
const spec = resolveRepoSpecFn({ repoRoot, vcs: vcsResolved });
|
|
545
|
+
|
|
440
546
|
// Dedup key: (source, title) pair — two different sources with the same
|
|
441
547
|
// title are genuinely distinct broken windows and each file separately.
|
|
442
548
|
const taskHash = computeTaskHash(`${source}::${title}`);
|
|
443
549
|
|
|
444
|
-
const existing = await findExistingBrokenWindow({
|
|
550
|
+
const existing = await findExistingBrokenWindow({
|
|
551
|
+
taskHash,
|
|
552
|
+
vcs: vcsResolved,
|
|
553
|
+
repoRoot,
|
|
554
|
+
resolveRepoSpecFn: () => spec,
|
|
555
|
+
});
|
|
445
556
|
if (existing.exists) {
|
|
446
557
|
return {
|
|
447
558
|
created: false,
|
|
@@ -453,7 +564,7 @@ export async function createBrokenWindowIssue({
|
|
|
453
564
|
|
|
454
565
|
const issueTitle = `[Broken-Window] ${truncate(title, 80)}`;
|
|
455
566
|
const body = buildBrokenWindowBody({ item, taskHash, dueDate, vcs: vcsResolved });
|
|
456
|
-
const labels = 'broken-window,priority
|
|
567
|
+
const labels = 'broken-window,priority::high';
|
|
457
568
|
|
|
458
569
|
let cmd;
|
|
459
570
|
let args;
|
|
@@ -475,6 +586,7 @@ export async function createBrokenWindowIssue({
|
|
|
475
586
|
dueDate,
|
|
476
587
|
];
|
|
477
588
|
}
|
|
589
|
+
if (spec) args.push('-R', spec);
|
|
478
590
|
|
|
479
591
|
const res = runCli(cmd, args);
|
|
480
592
|
if (!res.ok) {
|
|
@@ -76,6 +76,46 @@ export function writeMissionStatus(contents, missionStatusArray) {
|
|
|
76
76
|
return updateFrontmatterFields(contents, { 'mission-status': missionStatusArray.slice() });
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Mirrors `status` onto the frontmatter `mission-status` entry whose `id` matches
|
|
81
|
+
* `taskId`, returning a NEW frontmatter object (copy-on-write at object, array and
|
|
82
|
+
* entry level — the input is never mutated, which keeps `parseMissionStatus`'s
|
|
83
|
+
* shallow-copy contract intact for anything else holding the same nested entries).
|
|
84
|
+
*
|
|
85
|
+
* UPDATE-ONLY by design: when the key is absent, is not an array, or holds no entry
|
|
86
|
+
* with a matching `id`, the frontmatter is returned unchanged. It is deliberately
|
|
87
|
+
* neither created nor an error, because `setMissionStatus(contents, taskId, status)`
|
|
88
|
+
* knows only `id` and `status` — it lacks the `task` and `wave` fields that
|
|
89
|
+
* `validateMissionStatusEntry` (mission-status-schema.mjs) requires, so a synthesised
|
|
90
|
+
* entry would be schema-invalid yet look authoritative to frontmatter consumers such
|
|
91
|
+
* as `vault-status/narrative-mirror.mjs`. Throwing is likewise excluded by the
|
|
92
|
+
* never-throw contract of `setMissionStatus`.
|
|
93
|
+
*
|
|
94
|
+
* `status` is mirrored verbatim without an enum check on purpose: gating it would
|
|
95
|
+
* reintroduce the exact divergence (body says X, frontmatter says Y) this sync exists
|
|
96
|
+
* to remove. An out-of-enum value now lands on BOTH surfaces, where the repo's own
|
|
97
|
+
* validator can see it.
|
|
98
|
+
*
|
|
99
|
+
* @param {object} frontmatter
|
|
100
|
+
* @param {string} taskId
|
|
101
|
+
* @param {string} status
|
|
102
|
+
* @returns {object}
|
|
103
|
+
*/
|
|
104
|
+
function syncFrontmatterMissionStatus(frontmatter, taskId, status) {
|
|
105
|
+
if (frontmatter === null || typeof frontmatter !== 'object' || Array.isArray(frontmatter)) {
|
|
106
|
+
return frontmatter;
|
|
107
|
+
}
|
|
108
|
+
const raw = frontmatter['mission-status'];
|
|
109
|
+
if (!Array.isArray(raw)) return frontmatter;
|
|
110
|
+
const idx = raw.findIndex(
|
|
111
|
+
(e) => e !== null && typeof e === 'object' && !Array.isArray(e) && e.id === taskId
|
|
112
|
+
);
|
|
113
|
+
if (idx === -1) return frontmatter;
|
|
114
|
+
const entries = raw.slice();
|
|
115
|
+
entries[idx] = { ...raw[idx], status };
|
|
116
|
+
return { ...frontmatter, 'mission-status': entries };
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
/**
|
|
80
120
|
* Sets (or updates) the mission status for a single task in the `## Mission Status` body
|
|
81
121
|
* section of STATE.md. Creates the section if it does not exist.
|
|
@@ -83,6 +123,13 @@ export function writeMissionStatus(contents, missionStatusArray) {
|
|
|
83
123
|
* Format of each entry in the section:
|
|
84
124
|
* - <taskId>: <status> (updated <ISO timestamp>)
|
|
85
125
|
*
|
|
126
|
+
* Also mirrors `status` into the frontmatter `mission-status` entry with the same `id`
|
|
127
|
+
* (issue #960 — one writer, two sinks). The body section is what the coordinator writes
|
|
128
|
+
* during a wave; the frontmatter array is what `parseMissionStatus` consumers read
|
|
129
|
+
* (`vault-status/narrative-mirror.mjs`, session-end Phase 1.9/1.10). Before this sync the
|
|
130
|
+
* live writer and the reader sat on different surfaces and drifted apart in both
|
|
131
|
+
* directions. Frontmatter mirroring is UPDATE-ONLY — see `syncFrontmatterMissionStatus`.
|
|
132
|
+
*
|
|
86
133
|
* Pure function — no I/O. Returns original `contents` unchanged on bad input.
|
|
87
134
|
*
|
|
88
135
|
* @param {string} contents - Current STATE.md file contents (string)
|
|
@@ -97,6 +144,9 @@ export function setMissionStatus(contents, taskId, status) {
|
|
|
97
144
|
const parsed = parseStateMd(contents);
|
|
98
145
|
if (parsed === null) return contents;
|
|
99
146
|
|
|
147
|
+
// Computed once so every return path below emits the same synced frontmatter.
|
|
148
|
+
const frontmatter = syncFrontmatterMissionStatus(parsed.frontmatter, taskId, status);
|
|
149
|
+
|
|
100
150
|
const timestamp = new Date().toISOString();
|
|
101
151
|
const bullet = `- ${taskId}: ${status} (updated ${timestamp})`;
|
|
102
152
|
const lines = parsed.body.split('\n');
|
|
@@ -115,7 +165,7 @@ export function setMissionStatus(contents, taskId, status) {
|
|
|
115
165
|
let bodyOut = parsed.body;
|
|
116
166
|
if (!bodyOut.endsWith('\n')) bodyOut += '\n';
|
|
117
167
|
bodyOut += `\n## Mission Status\n\n${bullet}\n`;
|
|
118
|
-
return serializeStateMd({ frontmatter
|
|
168
|
+
return serializeStateMd({ frontmatter, body: bodyOut });
|
|
119
169
|
}
|
|
120
170
|
|
|
121
171
|
// Find end of section: next ## heading or end of lines
|
|
@@ -155,11 +205,11 @@ export function setMissionStatus(contents, taskId, status) {
|
|
|
155
205
|
const before = lines.slice(0, headingIdx + 1);
|
|
156
206
|
const after = lines.slice(insertAt);
|
|
157
207
|
const rebuilt = [...before, '', bullet, ...after];
|
|
158
|
-
return serializeStateMd({ frontmatter
|
|
208
|
+
return serializeStateMd({ frontmatter, body: rebuilt.join('\n') });
|
|
159
209
|
}
|
|
160
210
|
}
|
|
161
211
|
|
|
162
|
-
return serializeStateMd({ frontmatter
|
|
212
|
+
return serializeStateMd({ frontmatter, body: lines.join('\n') });
|
|
163
213
|
}
|
|
164
214
|
|
|
165
215
|
/**
|
|
@@ -14,7 +14,20 @@
|
|
|
14
14
|
* schema_version 1 (integer)
|
|
15
15
|
*
|
|
16
16
|
* Required for event='stop':
|
|
17
|
-
* duration_ms positive integer — wall-clock time from start to stop
|
|
17
|
+
* duration_ms positive integer | null — wall-clock time from start to stop.
|
|
18
|
+
* null means "unknown": the producer could not recover a
|
|
19
|
+
* matching 'start' record to measure against (#917). null is
|
|
20
|
+
* an honest absence and is NOT interchangeable with 0.
|
|
21
|
+
*
|
|
22
|
+
* duration_ms provenance (#917) — why the write path and the read path differ:
|
|
23
|
+
* Before #917 the producer defaulted duration_ms to 0 whenever the harness
|
|
24
|
+
* omitted it, which it always does. Every stop record written up to that point
|
|
25
|
+
* therefore carries a fabricated 0 (measured 2026-07-30: 2770 of 2770 stop
|
|
26
|
+
* records in .orchestrator/metrics/subagents.jsonl). Those records still have
|
|
27
|
+
* to read and migrate cleanly, so validation is lenient BY DEFAULT and accepts
|
|
28
|
+
* 0. Callers on the WRITE path opt into `{ strictDuration: true }`, which
|
|
29
|
+
* rejects 0 outright — appendSubagent() does this, so no new record can
|
|
30
|
+
* reintroduce the fabricated zero.
|
|
18
31
|
*
|
|
19
32
|
* Optional:
|
|
20
33
|
* agent_type string | null — e.g. 'explore', 'writer', 'test-writer'
|
|
@@ -70,14 +83,21 @@ export class ValidationError extends Error {
|
|
|
70
83
|
* violation. Does NOT mutate the input.
|
|
71
84
|
*
|
|
72
85
|
* Required fields: timestamp, event, agent_id, schema_version.
|
|
73
|
-
* Additional requirement when event='stop': duration_ms (
|
|
86
|
+
* Additional requirement when event='stop': duration_ms (integer or null).
|
|
74
87
|
* Optional: agent_type, parent_session_id, token_input, token_output.
|
|
75
88
|
*
|
|
76
89
|
* @param {object} entry
|
|
90
|
+
* @param {object} [options]
|
|
91
|
+
* @param {boolean} [options.strictDuration=false] — when true, a stop record's
|
|
92
|
+
* duration_ms must be a POSITIVE integer or null; 0 is rejected. Write-path
|
|
93
|
+
* callers set this; readers/migrations leave it false so the pre-#917 corpus
|
|
94
|
+
* (every stop record carrying a fabricated 0) still validates. See the module
|
|
95
|
+
* header § duration_ms provenance.
|
|
77
96
|
* @returns {object} the entry (unchanged) — validation is side-effect-free
|
|
78
97
|
* @throws {ValidationError}
|
|
79
98
|
*/
|
|
80
|
-
export function validateSubagent(entry) {
|
|
99
|
+
export function validateSubagent(entry, options = {}) {
|
|
100
|
+
const strictDuration = options?.strictDuration === true;
|
|
81
101
|
if (!entry || typeof entry !== 'object') {
|
|
82
102
|
throw new ValidationError('subagent record must be a non-null object');
|
|
83
103
|
}
|
|
@@ -114,15 +134,24 @@ export function validateSubagent(entry) {
|
|
|
114
134
|
throw new ValidationError('agent_id must be a non-empty string', 'agent_id');
|
|
115
135
|
}
|
|
116
136
|
|
|
117
|
-
// duration_ms — required for stop events
|
|
118
|
-
|
|
137
|
+
// duration_ms — required for stop events.
|
|
138
|
+
//
|
|
139
|
+
// null is an explicit, honest "duration unknown" (#917) — the producer found no
|
|
140
|
+
// matching 'start' record to measure against. It is deliberately distinct from
|
|
141
|
+
// 0, which under strictDuration is rejected as the signature of the pre-#917
|
|
142
|
+
// fabricated default. `undefined` still throws in BOTH modes: a stop record
|
|
143
|
+
// that omits the field entirely is a producer bug, not an unknown duration.
|
|
144
|
+
if (entry.event === 'stop' && entry.duration_ms !== null) {
|
|
145
|
+
const floor = strictDuration ? 1 : 0;
|
|
119
146
|
if (
|
|
120
147
|
typeof entry.duration_ms !== 'number' ||
|
|
121
148
|
!Number.isInteger(entry.duration_ms) ||
|
|
122
|
-
entry.duration_ms <
|
|
149
|
+
entry.duration_ms < floor
|
|
123
150
|
) {
|
|
124
151
|
throw new ValidationError(
|
|
125
|
-
|
|
152
|
+
strictDuration
|
|
153
|
+
? 'duration_ms must be a positive integer or null when event=stop (0 is not a measurement)'
|
|
154
|
+
: 'duration_ms must be a non-negative integer or null when event=stop',
|
|
126
155
|
'duration_ms',
|
|
127
156
|
);
|
|
128
157
|
}
|
|
@@ -252,11 +281,16 @@ export function migrateLegacySubagent(entry) {
|
|
|
252
281
|
* Steps:
|
|
253
282
|
* 1. Stamp schema_version if missing.
|
|
254
283
|
* 2. Normalize (apply optional-field defaults).
|
|
255
|
-
* 3. Validate — throws ValidationError on bad input.
|
|
284
|
+
* 3. Validate with strictDuration — throws ValidationError on bad input.
|
|
256
285
|
* 4. Serialize to JSON + newline.
|
|
257
286
|
* 5. mkdir(dirname, recursive: true).
|
|
258
287
|
* 6. appendFile (POSIX append ≤ PIPE_BUF is atomic for typical JSONL lines).
|
|
259
288
|
*
|
|
289
|
+
* This is the WRITE path, so it validates with `{ strictDuration: true }`: a stop
|
|
290
|
+
* record may carry a positive duration or an explicit null, never 0 (#917). The
|
|
291
|
+
* historic corpus is unaffected — it is only ever read, and readers/migrations use
|
|
292
|
+
* the lenient default. See the module header § duration_ms provenance.
|
|
293
|
+
*
|
|
260
294
|
* @param {string} filePath — absolute path to target .jsonl file
|
|
261
295
|
* @param {object} entry — candidate subagent record
|
|
262
296
|
* @returns {Promise<object>} the validated + normalized entry that was written
|
|
@@ -268,7 +302,7 @@ export async function appendSubagent(filePath, entry) {
|
|
|
268
302
|
schema_version: entry?.schema_version ?? CURRENT_SCHEMA_VERSION,
|
|
269
303
|
};
|
|
270
304
|
const normalized = normalizeSubagent(stamped);
|
|
271
|
-
const validated = validateSubagent(normalized);
|
|
305
|
+
const validated = validateSubagent(normalized, { strictDuration: true });
|
|
272
306
|
const line = JSON.stringify(validated) + '\n';
|
|
273
307
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
274
308
|
await appendFile(filePath, line, 'utf8');
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* telemetry/anon-id.mjs — rotating anonymous ID for usage-telemetry (Epic #841,
|
|
3
|
+
* S2 / GitLab #843; PRD docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA2).
|
|
4
|
+
*
|
|
5
|
+
* PURE, no I/O. The anonymous ID is a random UUID that rotates every
|
|
6
|
+
* ANON_ID_MAX_AGE_DAYS days. It is NEVER machine-derived (no hostname, MAC,
|
|
7
|
+
* install path, or any stable hardware/user identifier) — this is the privacy
|
|
8
|
+
* invariant that avoids the persistent-ID correlation criticism (PRD §4
|
|
9
|
+
* "Privacy engineering"). Rotation discards the old ID entirely.
|
|
10
|
+
*
|
|
11
|
+
* All time is passed IN as a parameter (`now`), never read from the clock inside
|
|
12
|
+
* this module, so callers stay deterministic and testable.
|
|
13
|
+
*
|
|
14
|
+
* Contract:
|
|
15
|
+
* newAnonId() → a fresh random UUID (v4).
|
|
16
|
+
* isExpired(createdAtISO, now, maxAge) → boolean; unparsable createdAt ⇒ true.
|
|
17
|
+
* ensureAnonId(record, opts) → { record, anon_id, rotated, created }.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { randomUUID } from 'node:crypto';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** Rotate the anonymous ID after this many days. */
|
|
27
|
+
export const ANON_ID_MAX_AGE_DAYS = 90;
|
|
28
|
+
|
|
29
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
function isPlainObject(v) {
|
|
36
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve a `now` argument to epoch-ms. Accepts either a number (already epoch-ms)
|
|
41
|
+
* or an ISO 8601 string. Returns NaN when it cannot be parsed.
|
|
42
|
+
* @param {number|string} now
|
|
43
|
+
* @returns {number}
|
|
44
|
+
*/
|
|
45
|
+
function toEpochMs(now) {
|
|
46
|
+
if (typeof now === 'number') return now;
|
|
47
|
+
if (typeof now === 'string') return Date.parse(now);
|
|
48
|
+
return NaN;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Public API
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mint a fresh anonymous ID. A random UUID (v4) — never derived from any machine
|
|
57
|
+
* or user attribute.
|
|
58
|
+
*
|
|
59
|
+
* @returns {string} a v4 UUID
|
|
60
|
+
*/
|
|
61
|
+
export function newAnonId() {
|
|
62
|
+
return randomUUID();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decide whether an anonymous ID minted at `createdAtISO` is older than
|
|
67
|
+
* `maxAgeDays` relative to `now` and must be rotated.
|
|
68
|
+
*
|
|
69
|
+
* Fail-safe posture: an unparsable / missing `createdAtISO` (or `now`) returns
|
|
70
|
+
* `true` (rotate) — an ID whose age cannot be verified is treated as stale
|
|
71
|
+
* rather than trusted indefinitely. The age comparison is strict: exactly
|
|
72
|
+
* `maxAgeDays` old is NOT expired (only strictly older rotates).
|
|
73
|
+
*
|
|
74
|
+
* @param {string} createdAtISO — ISO 8601 timestamp the current ID was minted at.
|
|
75
|
+
* @param {number|string} now — reference time (epoch-ms or ISO 8601 string).
|
|
76
|
+
* @param {number} [maxAgeDays=ANON_ID_MAX_AGE_DAYS]
|
|
77
|
+
* @returns {boolean} true ⇒ rotate.
|
|
78
|
+
*/
|
|
79
|
+
export function isExpired(createdAtISO, now, maxAgeDays = ANON_ID_MAX_AGE_DAYS) {
|
|
80
|
+
const createdMs = typeof createdAtISO === 'string' ? Date.parse(createdAtISO) : NaN;
|
|
81
|
+
if (Number.isNaN(createdMs)) return true; // unverifiable age ⇒ rotate
|
|
82
|
+
|
|
83
|
+
const nowMs = toEpochMs(now);
|
|
84
|
+
if (Number.isNaN(nowMs)) return true; // unverifiable reference ⇒ rotate
|
|
85
|
+
|
|
86
|
+
const ageMs = nowMs - createdMs;
|
|
87
|
+
const maxMs = maxAgeDays * MS_PER_DAY;
|
|
88
|
+
return ageMs > maxMs;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Ensure `record` carries a fresh-enough anonymous ID, returning a NEW record
|
|
93
|
+
* (the input is never mutated). Three outcomes:
|
|
94
|
+
*
|
|
95
|
+
* - created — the record had no `anon_id`: mint one, stamp `anon_id_created_at`
|
|
96
|
+
* to `now`. Returns `{ created: true, rotated: false }`.
|
|
97
|
+
* - rotated — the existing ID is older than `maxAgeDays` (or its
|
|
98
|
+
* `anon_id_created_at` is unparsable): mint a new one, re-stamp
|
|
99
|
+
* `anon_id_created_at`, discard the old ID.
|
|
100
|
+
* Returns `{ created: false, rotated: true }`.
|
|
101
|
+
* - unchanged — the ID is present and fresh: return it as-is (in a shallow
|
|
102
|
+
* copy). Returns `{ created: false, rotated: false }`.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} record — a record that may carry `anon_id` + `anon_id_created_at`.
|
|
105
|
+
* @param {{now?: string, maxAgeDays?: number}} [opts]
|
|
106
|
+
* @returns {{record: object, anon_id: string, rotated: boolean, created: boolean}}
|
|
107
|
+
*/
|
|
108
|
+
export function ensureAnonId(record, { now = new Date().toISOString(), maxAgeDays = ANON_ID_MAX_AGE_DAYS } = {}) {
|
|
109
|
+
const rec = isPlainObject(record) ? record : {};
|
|
110
|
+
const currentId = rec.anon_id;
|
|
111
|
+
|
|
112
|
+
// created — no usable ID present.
|
|
113
|
+
if (typeof currentId !== 'string' || currentId.trim() === '') {
|
|
114
|
+
const anon_id = newAnonId();
|
|
115
|
+
return {
|
|
116
|
+
record: { ...rec, anon_id, anon_id_created_at: now },
|
|
117
|
+
anon_id,
|
|
118
|
+
rotated: false,
|
|
119
|
+
created: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// rotated — present ID is too old (or its created_at is unparsable).
|
|
124
|
+
if (isExpired(rec.anon_id_created_at, now, maxAgeDays)) {
|
|
125
|
+
const anon_id = newAnonId();
|
|
126
|
+
return {
|
|
127
|
+
record: { ...rec, anon_id, anon_id_created_at: now },
|
|
128
|
+
anon_id,
|
|
129
|
+
rotated: true,
|
|
130
|
+
created: false,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// unchanged — present and fresh. Return a copy so the input stays untouched.
|
|
135
|
+
return {
|
|
136
|
+
record: { ...rec },
|
|
137
|
+
anon_id: currentId,
|
|
138
|
+
rotated: false,
|
|
139
|
+
created: false,
|
|
140
|
+
};
|
|
141
|
+
}
|