session-orchestrator 4.0.0 → 4.1.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 +3 -2
- package/.codex-plugin/skills/architecture/SKILL.md +20 -0
- package/.codex-plugin/skills/autopilot/SKILL.md +21 -0
- package/.codex-plugin/skills/autopilot/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/bootstrap/SKILL.md +22 -0
- package/.codex-plugin/skills/bootstrap/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/brainstorm/SKILL.md +22 -0
- package/.codex-plugin/skills/brainstorm/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/claude-md-drift-check/SKILL.md +17 -0
- package/.codex-plugin/skills/close/SKILL.md +21 -0
- package/.codex-plugin/skills/close/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/convergence-monitoring/SKILL.md +24 -0
- package/.codex-plugin/skills/debug/SKILL.md +21 -0
- package/.codex-plugin/skills/debug/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/discovery/SKILL.md +21 -0
- package/.codex-plugin/skills/discovery/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/dispatcher/SKILL.md +21 -0
- package/.codex-plugin/skills/dispatcher/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/docs-orchestrator/SKILL.md +20 -0
- package/.codex-plugin/skills/ecosystem-health/SKILL.md +22 -0
- package/.codex-plugin/skills/eli5/SKILL.md +21 -0
- package/.codex-plugin/skills/eli5/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/eval/SKILL.md +21 -0
- package/.codex-plugin/skills/eval/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/evolve/SKILL.md +21 -0
- package/.codex-plugin/skills/evolve/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/frontmatter-guard/SKILL.md +17 -0
- package/.codex-plugin/skills/gitlab-ops/SKILL.md +22 -0
- package/.codex-plugin/skills/gitlab-portfolio/SKILL.md +17 -0
- package/.codex-plugin/skills/go/SKILL.md +22 -0
- package/.codex-plugin/skills/go/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/grill/SKILL.md +21 -0
- package/.codex-plugin/skills/grill/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/harness-audit/SKILL.md +19 -0
- package/.codex-plugin/skills/harness-audit/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/hook-development/SKILL.md +17 -0
- package/.codex-plugin/skills/mcp-builder/SKILL.md +17 -0
- package/.codex-plugin/skills/memory-cleanup/SKILL.md +21 -0
- package/.codex-plugin/skills/memory-cleanup/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/mode-selector/SKILL.md +19 -0
- package/.codex-plugin/skills/npm-publish/SKILL.md +18 -0
- package/.codex-plugin/skills/peekaboo-driver/SKILL.md +20 -0
- package/.codex-plugin/skills/persona-panel/SKILL.md +22 -0
- package/.codex-plugin/skills/persona-panel/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/plan/SKILL.md +22 -0
- package/.codex-plugin/skills/plan/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/playwright-driver/SKILL.md +22 -0
- package/.codex-plugin/skills/portfolio/SKILL.md +21 -0
- package/.codex-plugin/skills/portfolio/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/quality-gates/SKILL.md +22 -0
- package/.codex-plugin/skills/reconcile/SKILL.md +21 -0
- package/.codex-plugin/skills/reconcile/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/release/SKILL.md +22 -0
- package/.codex-plugin/skills/release/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/remote-offload/SKILL.md +22 -0
- package/.codex-plugin/skills/repo-audit/SKILL.md +19 -0
- package/.codex-plugin/skills/repo-audit/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/session/SKILL.md +21 -0
- package/.codex-plugin/skills/session/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/session-end/SKILL.md +22 -0
- package/.codex-plugin/skills/session-plan/SKILL.md +22 -0
- package/.codex-plugin/skills/session-start/SKILL.md +22 -0
- package/.codex-plugin/skills/spinout/SKILL.md +21 -0
- package/.codex-plugin/skills/spinout/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/sunset-review/SKILL.md +21 -0
- package/.codex-plugin/skills/sunset-review/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/templates-ack/SKILL.md +21 -0
- package/.codex-plugin/skills/templates-ack/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test/SKILL.md +21 -0
- package/.codex-plugin/skills/test/agents/openai.yaml +5 -0
- package/.codex-plugin/skills/test-runner/SKILL.md +22 -0
- package/.codex-plugin/skills/tmux-layout/SKILL.md +23 -0
- package/.codex-plugin/skills/using-orchestrator/SKILL.md +19 -0
- package/.codex-plugin/skills/vault-mirror/SKILL.md +17 -0
- package/.codex-plugin/skills/vault-sync/SKILL.md +17 -0
- package/.codex-plugin/skills/wave-executor/SKILL.md +22 -0
- package/.codex-plugin/skills/write-executable-plan/SKILL.md +24 -0
- package/{plugin.json → .cursor-plugin/plugin.json} +5 -2
- package/CHANGELOG.md +213 -1
- package/README.md +70 -58
- package/commands/release.md +4 -4
- package/docs/codex-setup.md +43 -9
- package/docs/components.md +3 -2
- package/docs/instruction-delivery.md +12 -5
- package/docs/migration-v4.md +33 -9
- package/hooks/_lib/hook-import-set.json +4 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-stop.mjs +25 -4
- package/package.json +2 -2
- package/scripts/ci/assert-coverage-green.mjs +100 -0
- package/scripts/generate-codex-skills.mjs +246 -0
- package/scripts/generate-hook-import-set.mjs +51 -8
- package/scripts/lib/codex/plugin-contract.mjs +6 -0
- package/scripts/lib/config/host-paths.mjs +20 -4
- package/scripts/lib/events.mjs +3 -3
- package/scripts/lib/gates/gate-full.mjs +7 -3
- package/scripts/lib/owner-config-banner.mjs +7 -9
- package/scripts/lib/owner-yaml.mjs +8 -1
- package/scripts/lib/plugin-update-banner.mjs +10 -2
- package/scripts/lib/project-hygiene.mjs +182 -6
- package/scripts/lib/reconcile/engine.mjs +38 -7
- package/scripts/lib/session-identity/own-session.mjs +24 -13
- package/scripts/lib/session-schema/constants.mjs +38 -11
- package/scripts/lib/session-start-probes.mjs +12 -0
- package/scripts/lib/telemetry/schema.mjs +39 -18
- package/scripts/lib/telemetry-flush-health-banner.mjs +211 -0
- package/scripts/lib/validate/check-codex-skills.mjs +191 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +107 -62
- package/scripts/lib/validate/check-skill-links.mjs +37 -7
- package/scripts/lib/validate/check-test-git-config-target.mjs +192 -12
- package/scripts/lib/validate/check-unwired-features.mjs +163 -13
- package/scripts/lib/validate/confidential-names.mjs +95 -30
- package/scripts/lib/validate/repo-files.mjs +48 -14
- package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
- package/scripts/release.mjs +141 -29
- package/scripts/site-numbers.mjs +344 -8
- package/scripts/validate-plugin.mjs +3 -0
- package/skills/session-start/SKILL.md +2 -2
- package/skills/session-start/references/phase-4-ssot-environment-check.md +5 -0
- package/skills/vault-sync/SKILL.md +10 -0
|
@@ -346,7 +346,8 @@ function resolvePlatformFromEnv(env) {
|
|
|
346
346
|
* @param {"claude"|"codex"|"cursor"|"pi"} [opts.platform] The harness driving
|
|
347
347
|
* this session, when the caller already knows it (the SessionStart hook
|
|
348
348
|
* computes this once and could pass it straight through). Falls back to
|
|
349
|
-
* {@link resolvePlatformFromEnv} on `opts.env` when omitted
|
|
349
|
+
* {@link resolvePlatformFromEnv} on `opts.env` when omitted OR when the value
|
|
350
|
+
* is not one of the four keys above — never throws,
|
|
350
351
|
* never leaves the instruction generic just because the caller didn't wire
|
|
351
352
|
* the parameter through yet.
|
|
352
353
|
* @returns {Promise<{severity: 'warn', message: string, installed: string, latest: string}|null>}
|
|
@@ -391,8 +392,15 @@ export async function checkPluginUpdate({
|
|
|
391
392
|
return null;
|
|
392
393
|
}
|
|
393
394
|
|
|
395
|
+
// Allowlist, not "any non-empty string": only a value this table actually has
|
|
396
|
+
// a recipe for may pre-empt env resolution — a typo or a future harness name
|
|
397
|
+
// (`'CLAUDE'`, `'clade'`) falls through to the env signal instead of silently
|
|
398
|
+
// degrading to the generic instruction. `Object.hasOwn`, never `in`: `in`
|
|
399
|
+
// would accept prototype keys (`'toString'`) as platforms.
|
|
394
400
|
const resolvedPlatform =
|
|
395
|
-
typeof platform === 'string' &&
|
|
401
|
+
typeof platform === 'string' && Object.hasOwn(PLATFORM_UPDATE_INSTRUCTIONS, platform)
|
|
402
|
+
? platform
|
|
403
|
+
: resolvePlatformFromEnv(env);
|
|
396
404
|
const instruction = PLATFORM_UPDATE_INSTRUCTIONS[resolvedPlatform] ?? GENERIC_UPDATE_INSTRUCTION;
|
|
397
405
|
|
|
398
406
|
return {
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
import { execFileSync } from 'node:child_process';
|
|
45
45
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
46
46
|
import { join } from 'node:path';
|
|
47
|
+
import yaml from 'js-yaml';
|
|
48
|
+
import { tokenizeCommand, splitChainSegments, resolveSegmentVerb } from './command-blocker.mjs';
|
|
47
49
|
|
|
48
50
|
/** Commits past the newest tag before release hygiene is worth mentioning. */
|
|
49
51
|
export const DEFAULT_RELEASE_DRIFT_COMMITS = 50;
|
|
@@ -408,8 +410,8 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
408
410
|
/**
|
|
409
411
|
* H4 — CI configuration hygiene.
|
|
410
412
|
*
|
|
411
|
-
* Measured 5/6.
|
|
412
|
-
* pipeline
|
|
413
|
+
* Measured 5/6. Checks locally declared executable commands without evaluating
|
|
414
|
+
* pipeline conditions or external includes. The dependency-audit gap is the load-bearing one: three
|
|
413
415
|
* of the tested repos carried known-vulnerable dependencies that no pipeline
|
|
414
416
|
* would ever surface.
|
|
415
417
|
*
|
|
@@ -438,19 +440,21 @@ export function checkCiConfig(repoRoot) {
|
|
|
438
440
|
return findings;
|
|
439
441
|
}
|
|
440
442
|
|
|
441
|
-
|
|
442
|
-
if (hasGitlab)
|
|
443
|
+
const ciConfigs = [];
|
|
444
|
+
if (hasGitlab) ciConfigs.push({ text: safeRead(gitlabCi), platform: 'gitlab' });
|
|
443
445
|
if (hasGithub) {
|
|
444
446
|
try {
|
|
445
447
|
for (const f of readdirSync(ghWorkflows)) {
|
|
446
|
-
if (f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
448
|
+
if (f.endsWith('.yml') || f.endsWith('.yaml')) {
|
|
449
|
+
ciConfigs.push({ text: safeRead(join(ghWorkflows, f)), platform: 'github' });
|
|
450
|
+
}
|
|
447
451
|
}
|
|
448
452
|
} catch {
|
|
449
453
|
/* unreadable workflows dir — fall through with what we have */
|
|
450
454
|
}
|
|
451
455
|
}
|
|
452
456
|
|
|
453
|
-
if (
|
|
457
|
+
if (ciConfigs.some(({ text }) => text) && !ciConfigs.some(hasAuditStep)) {
|
|
454
458
|
findings.push({
|
|
455
459
|
check: 'ci-audit-job',
|
|
456
460
|
fixable: true,
|
|
@@ -461,6 +465,178 @@ export function checkCiConfig(repoRoot) {
|
|
|
461
465
|
return findings;
|
|
462
466
|
}
|
|
463
467
|
|
|
468
|
+
const GITLAB_COMMAND_FIELDS = ['script', 'before_script', 'after_script'];
|
|
469
|
+
const GITLAB_GLOBAL_FIELDS = new Set([
|
|
470
|
+
'stages', 'types', 'variables', 'default', 'include', 'workflow',
|
|
471
|
+
'image', 'services', 'cache', 'before_script', 'after_script', 'spec',
|
|
472
|
+
]);
|
|
473
|
+
const GITLAB_REFERENCE = Symbol('gitlab-reference');
|
|
474
|
+
const GITLAB_SCHEMA = yaml.DEFAULT_SCHEMA.extend([
|
|
475
|
+
new yaml.Type('!reference', {
|
|
476
|
+
kind: 'sequence',
|
|
477
|
+
construct: (path) => ({ [GITLAB_REFERENCE]: path }),
|
|
478
|
+
}),
|
|
479
|
+
]);
|
|
480
|
+
const AUDIT_VALUE_FLAGS = new Set([
|
|
481
|
+
'--filter', '-F', '--dir', '-C', '--prefix', '--cwd', '--registry',
|
|
482
|
+
'--userconfig', '--globalconfig', '--workspace', '-w', '--cache', '--pm-on-fail',
|
|
483
|
+
]);
|
|
484
|
+
const AUDIT_BOOLEAN_FLAGS = new Set([
|
|
485
|
+
'-r', '--recursive', '-s', '--silent', '-g', '--global', '--workspaces', '--offline',
|
|
486
|
+
]);
|
|
487
|
+
|
|
488
|
+
/** Inspect executable job/step locations, never arbitrary keys named run/script. */
|
|
489
|
+
function hasAuditStep({ text, platform }) {
|
|
490
|
+
let config;
|
|
491
|
+
try {
|
|
492
|
+
if (platform === 'gitlab') {
|
|
493
|
+
const documents = yaml.loadAll(text, undefined, { schema: GITLAB_SCHEMA });
|
|
494
|
+
if (documents.length === 1) {
|
|
495
|
+
[config] = documents;
|
|
496
|
+
} else if (documents.length === 2 && isConfigMap(documents[0])
|
|
497
|
+
&& Object.keys(documents[0]).length === 1 && isConfigMap(documents[0].spec)) {
|
|
498
|
+
// GitLab permits a spec header followed by --- and the job document.
|
|
499
|
+
// Header input defaults are data; unrelated multi-document YAML is ambiguous.
|
|
500
|
+
[, config] = documents;
|
|
501
|
+
} else {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
} else {
|
|
505
|
+
config = yaml.load(text);
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
return false;
|
|
509
|
+
}
|
|
510
|
+
if (!isConfigMap(config)) return false;
|
|
511
|
+
if (platform === 'github') {
|
|
512
|
+
if (!isConfigMap(config.jobs)) return false;
|
|
513
|
+
return Object.values(config.jobs).some((job) => isConfigMap(job)
|
|
514
|
+
&& job.if !== false && Array.isArray(job.steps)
|
|
515
|
+
&& job.steps.some((step) => isConfigMap(step) && step.if !== false
|
|
516
|
+
&& typeof step.run === 'string' && hasAuditCommand(step.run)));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return Object.keys(config).some((name) => {
|
|
520
|
+
if (name.startsWith('.') || GITLAB_GLOBAL_FIELDS.has(name)) return false;
|
|
521
|
+
const job = resolveGitlabJob(config, name);
|
|
522
|
+
if (!job?.script || job.when === 'never') return false;
|
|
523
|
+
return GITLAB_COMMAND_FIELDS.some((field) => {
|
|
524
|
+
let commands = job[field];
|
|
525
|
+
if (!Object.hasOwn(job, field) && field !== 'script') {
|
|
526
|
+
const inherit = job.inherit?.default;
|
|
527
|
+
if (inherit === false || (Array.isArray(inherit) && !inherit.includes(field))) return false;
|
|
528
|
+
commands = Object.hasOwn(config.default ?? {}, field) ? config.default[field] : config[field];
|
|
529
|
+
}
|
|
530
|
+
return hasGitlabAuditCommands(commands, config);
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isConfigMap(value) {
|
|
536
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
537
|
+
&& !Object.hasOwn(value, GITLAB_REFERENCE);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Local extends uses later-parent/child replacement for command arrays. */
|
|
541
|
+
function resolveGitlabJob(config, name, seen = new Set()) {
|
|
542
|
+
if (typeof name !== 'string' || !Object.hasOwn(config, name)
|
|
543
|
+
|| !isConfigMap(config[name]) || seen.has(name)) return null;
|
|
544
|
+
seen.add(name);
|
|
545
|
+
const job = config[name];
|
|
546
|
+
let merged = {};
|
|
547
|
+
const parents = Array.isArray(job.extends) ? job.extends : [job.extends];
|
|
548
|
+
for (const parent of parents.filter((entry) => entry !== undefined)) {
|
|
549
|
+
const inherited = resolveGitlabJob(config, parent, seen);
|
|
550
|
+
// An unresolved later parent may override earlier commands. Only fields
|
|
551
|
+
// supplied by later local parents or the job itself remain evidence.
|
|
552
|
+
merged = inherited ? mergeGitlabJob(merged, inherited) : {};
|
|
553
|
+
}
|
|
554
|
+
seen.delete(name);
|
|
555
|
+
return mergeGitlabJob(merged, job);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function mergeGitlabJob(parent, child) {
|
|
559
|
+
const merged = { ...parent, ...child };
|
|
560
|
+
// Command fields replace wholesale; inherit is a map, so its default flag
|
|
561
|
+
// survives a child that overrides only inherit.variables.
|
|
562
|
+
if (isConfigMap(parent.inherit) && isConfigMap(child.inherit)) {
|
|
563
|
+
merged.inherit = { ...parent.inherit, ...child.inherit };
|
|
564
|
+
}
|
|
565
|
+
return merged;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** Resolve only command strings/arrays and local !reference paths, with cycle guards. */
|
|
569
|
+
function hasGitlabAuditCommands(commands, config) {
|
|
570
|
+
const seen = new WeakSet();
|
|
571
|
+
function visit(value) {
|
|
572
|
+
if (typeof value === 'string') return hasAuditCommand(value);
|
|
573
|
+
if (!value || typeof value !== 'object' || seen.has(value)) return false;
|
|
574
|
+
seen.add(value);
|
|
575
|
+
let found = false;
|
|
576
|
+
if (Array.isArray(value)) {
|
|
577
|
+
found = value.some(visit);
|
|
578
|
+
} else if (Array.isArray(value[GITLAB_REFERENCE])) {
|
|
579
|
+
let target = config;
|
|
580
|
+
for (const key of value[GITLAB_REFERENCE]) {
|
|
581
|
+
target = target && typeof target === 'object' && Object.hasOwn(target, key) ? target[key] : undefined;
|
|
582
|
+
}
|
|
583
|
+
found = visit(target);
|
|
584
|
+
}
|
|
585
|
+
seen.delete(value);
|
|
586
|
+
return found;
|
|
587
|
+
}
|
|
588
|
+
return visit(commands);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Bounded command recognition: wrappers and options may precede the audit
|
|
593
|
+
* subcommand, but another verb (install/run/echo) ends the search. Unknown
|
|
594
|
+
* value-taking flags and commands hidden in external includes are not resolved.
|
|
595
|
+
*/
|
|
596
|
+
function hasAuditCommand(command) {
|
|
597
|
+
return splitChainSegments(tokenizeCommand(command)).some((segment) => hasAuditSegment(segment));
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function hasAuditSegment(segment, depth = 0) {
|
|
601
|
+
const resolved = resolveSegmentVerb(segment);
|
|
602
|
+
if (resolved.alt) return false;
|
|
603
|
+
let { verb, index } = resolved;
|
|
604
|
+
if (resolved.payloads.length > 0) {
|
|
605
|
+
if (depth >= 3 || resolved.payloads.length !== 1) return false;
|
|
606
|
+
const tokens = tokenizeCommand(resolved.payloads[0]);
|
|
607
|
+
const parts = splitChainSegments(tokens);
|
|
608
|
+
// env -S splits argv; it does not execute shell operators. Restrict this
|
|
609
|
+
// reuse of the shared tokenizer to one plain command with no redirects.
|
|
610
|
+
if (parts.length !== 1 || parts[0].length !== tokens.length
|
|
611
|
+
|| tokens.some((token) => token.redirect)) return false;
|
|
612
|
+
const trailingArgs = index < 0 ? [] : segment.slice(index);
|
|
613
|
+
return hasAuditSegment([...tokens, ...trailingArgs], depth + 1);
|
|
614
|
+
}
|
|
615
|
+
if (verb === 'corepack' || verb === 'npx') {
|
|
616
|
+
index++;
|
|
617
|
+
while (['-y', '--yes', '--no-install', '--'].includes(segment[index]?.text)) index++;
|
|
618
|
+
verb = segment[index]?.text;
|
|
619
|
+
}
|
|
620
|
+
if (segment.slice(index + 1).some((token) => ['-h', '--help', '--version'].includes(token.text))) return false;
|
|
621
|
+
if (/^(?:pip-audit|osv-scanner|dependency-check(?:\.sh)?)$/.test(verb ?? '')) return true;
|
|
622
|
+
if (!/^(?:npm|pnpm|yarn|bun)(?:@[\w.+-]+)?$/.test(verb ?? '') && verb !== 'cargo') return false;
|
|
623
|
+
let i = index + 1;
|
|
624
|
+
while (segment[i]?.text.startsWith('-')) {
|
|
625
|
+
const flag = segment[i].text;
|
|
626
|
+
if (flag === '--') { i++; break; }
|
|
627
|
+
if (AUDIT_VALUE_FLAGS.has(flag)) {
|
|
628
|
+
if (!segment[i + 1] || segment[i + 1].redirect) return false;
|
|
629
|
+
i += 2;
|
|
630
|
+
} else if (AUDIT_BOOLEAN_FLAGS.has(flag) || /^--[\w-]+=/.test(flag)) {
|
|
631
|
+
i++;
|
|
632
|
+
} else {
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (/^yarn(?:@|$)/.test(verb) && segment[i]?.text === 'npm') i++;
|
|
637
|
+
return segment[i]?.text === 'audit';
|
|
638
|
+
}
|
|
639
|
+
|
|
464
640
|
/** @param {string} p @returns {string} */
|
|
465
641
|
function safeRead(p) {
|
|
466
642
|
try {
|
|
@@ -389,6 +389,28 @@ const BODY_LEARNING_ID_RE = /-\s*learning-id:\s*`([^`]+)`/g;
|
|
|
389
389
|
* on disk: re-proposing it is the issue #484 defect (9 of 10 proposals in one
|
|
390
390
|
* run were learnings a `.claude/rules/` file already covered).
|
|
391
391
|
*
|
|
392
|
+
* **This scan is the AUTHORITATIVE half of the dedupe contract** (#1242). The
|
|
393
|
+
* `.claude/rules/*.md` files it reads are TRACKED, so they survive a fresh
|
|
394
|
+
* clone, a wiped working copy, and any loss of `.orchestrator/runtime/` (which
|
|
395
|
+
* is gitignored — `.gitignore:114`). The idempotency sidecar consulted beside
|
|
396
|
+
* it is a CACHE that can only SHORT-CIRCUIT this scan, never replace it: on a
|
|
397
|
+
* fresh clone the sidecar is empty and correctness rests entirely on the
|
|
398
|
+
* markers below. Measured 2026-09-07 on this repo: with the sidecar emptied,
|
|
399
|
+
* the run produced the identical 10 proposals and 30 "already materialized"
|
|
400
|
+
* rejections; with this scan disabled instead, 5 already-consolidated
|
|
401
|
+
* learnings were re-proposed.
|
|
402
|
+
*
|
|
403
|
+
* Both marker forms are load-bearing. Frontmatter `learning-key:` is a YAML
|
|
404
|
+
* SCALAR and can name exactly ONE learning, so a CONSOLIDATED rule file (one
|
|
405
|
+
* file absorbing N learnings) carries the remaining N-1 identities ONLY as
|
|
406
|
+
* `## Provenance` body bullets. Breaking {@link BODY_LEARNING_KEY_RE} would
|
|
407
|
+
* therefore silently re-propose most of a consolidated corpus while every
|
|
408
|
+
* single-learning file still deduped correctly — pinned by the "fresh clone,
|
|
409
|
+
* consolidated shape" test in `tests/lib/reconcile/engine.test.mjs`.
|
|
410
|
+
*
|
|
411
|
+
* `rule-loader.mjs` only EXCLUDES expired rules from injection; it never
|
|
412
|
+
* deletes a file, so an expired rule keeps deduping through these markers.
|
|
413
|
+
*
|
|
392
414
|
* Gated the same way as {@link defaultLoadCandidatesForDedupe}: an absent
|
|
393
415
|
* `repoRoot` yields empty sets rather than falling back to `process.cwd()`.
|
|
394
416
|
* Never throws — a missing `.claude/rules/` dir or an unreadable file
|
|
@@ -555,14 +577,23 @@ async function runReconcileInner(
|
|
|
555
577
|
// new learning could use — the #484 defect measured on a real repo was
|
|
556
578
|
// exactly this: 9 of 10 proposals in one run were learnings that already
|
|
557
579
|
// had a `.claude/rules/` file on disk, crowding out the tenth new one.
|
|
558
|
-
// Two independent sources both count as terminal, either is sufficient
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
// called from this module — the other half of #484);
|
|
562
|
-
// - a `.claude/rules/*.md` file already carries a matching
|
|
580
|
+
// Two independent sources both count as terminal, either is sufficient —
|
|
581
|
+
// but they are NOT peers (#1242). Their contract is:
|
|
582
|
+
// - AUTHORITATIVE: a `.claude/rules/*.md` file already carries a matching
|
|
563
583
|
// `learning-key`/`learning-id` provenance marker, discovered by
|
|
564
|
-
// scanning disk directly
|
|
565
|
-
//
|
|
584
|
+
// scanning disk directly. Those files are TRACKED, so this source alone
|
|
585
|
+
// is sufficient on a fresh clone and covers rules written without ever
|
|
586
|
+
// going through the sidecar (hand-authored, or consolidated by hand).
|
|
587
|
+
// - CACHE: the idempotency sidecar already carries a `processed_at` stamp
|
|
588
|
+
// for this `learning_key` (`isProcessed`, previously computed but NEVER
|
|
589
|
+
// called from this module — the other half of #484). It lives in the
|
|
590
|
+
// GITIGNORED `.orchestrator/runtime/` (`.gitignore:114`), so it is
|
|
591
|
+
// absent on a fresh clone. It only SHORT-CIRCUITS the scan above (and
|
|
592
|
+
// carries forward verdicts the scan can no longer see, e.g. an operator
|
|
593
|
+
// decline (#1042) or a rule file since removed) — it never replaces it.
|
|
594
|
+
// Consequence: an empty sidecar must never change the verdict for a
|
|
595
|
+
// learning whose rule file exists. Pinned by the "fresh clone, consolidated
|
|
596
|
+
// shape" test in `tests/lib/reconcile/engine.test.mjs`.
|
|
566
597
|
const { records: existingCandidates } = loadCandidatesForDedupe(repoRoot) ?? { records: [] };
|
|
567
598
|
const materialized = readMaterializedProvenance(repoRoot) ?? { keys: new Set(), ids: new Set() };
|
|
568
599
|
|
|
@@ -80,8 +80,8 @@ function readLockIds(repoRoot) {
|
|
|
80
80
|
* handled right now (`session_id` / `sessionId`, plus `parent_session_id`
|
|
81
81
|
* for a sub-agent invocation, whose coordinator is equally us). The only
|
|
82
82
|
* tier that is per-INVOCATION rather than per-working-copy.
|
|
83
|
-
* 2.
|
|
84
|
-
*
|
|
83
|
+
* 2. The native process-scoped id (`CLAUDE_CODE_SESSION_ID` or
|
|
84
|
+
* `CODEX_THREAD_ID`), selected by {@link readProcessLocalSessionIds}.
|
|
85
85
|
* 3. `session.lock` `session_id` / `semantic_session_id` — repo-GLOBAL, and
|
|
86
86
|
* the identity the WRITER of a manifest uses: `wave-scope.json`'s
|
|
87
87
|
* `session` field comes from `sessionAttribution()`, which reads this same
|
|
@@ -138,20 +138,12 @@ function readLockIds(repoRoot) {
|
|
|
138
138
|
* never as a mismatch.
|
|
139
139
|
*/
|
|
140
140
|
export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
141
|
-
const ids = new Set();
|
|
141
|
+
const ids = new Set(readProcessLocalSessionIds({ hookInput }));
|
|
142
142
|
const add = (value) => {
|
|
143
143
|
const trimmed = typeof value === 'string' ? value.trim() : '';
|
|
144
144
|
if (trimmed) ids.add(trimmed);
|
|
145
145
|
};
|
|
146
146
|
|
|
147
|
-
// Source 1 — the harness's statement about THIS invocation.
|
|
148
|
-
if (hookInput && typeof hookInput === 'object') {
|
|
149
|
-
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Source 2 — process-scoped env var.
|
|
153
|
-
add(process.env.CLAUDE_CODE_SESSION_ID);
|
|
154
|
-
|
|
155
147
|
// Source 3 — repo-global lock file (the manifest writer's own identity).
|
|
156
148
|
try {
|
|
157
149
|
const lock = readLockIds(repoRoot);
|
|
@@ -190,6 +182,14 @@ export function readOwnSessionIds(repoRoot, { hookInput = null } = {}) {
|
|
|
190
182
|
* rather than unioning is the fix — a better signal REPLACES a worse one
|
|
191
183
|
* (`.claude/rules/host-resources.md` § HR-102).
|
|
192
184
|
*
|
|
185
|
+
* Native env identity (#1274): an explicit SO_PLATFORM selects ONLY its own
|
|
186
|
+
* variable (claude → CLAUDE_CODE_SESSION_ID, codex → CODEX_THREAD_ID). Cursor
|
|
187
|
+
* and Pi have no native env source here. Without a valid explicit selection,
|
|
188
|
+
* a single non-empty native id, or two equal ids, is usable; conflicting ids
|
|
189
|
+
* are ambiguous and contribute nothing. This prevents an inherited parent
|
|
190
|
+
* harness's id from confirming a peer lock. Hook invocation/parent ids remain
|
|
191
|
+
* valid independently. No filesystem/platform detection is an identity proof.
|
|
192
|
+
*
|
|
193
193
|
* Never throws.
|
|
194
194
|
*
|
|
195
195
|
* @param {{ env?: object, hookInput?: object|null }} [opts]
|
|
@@ -211,8 +211,19 @@ export function readProcessLocalSessionIds({ env = process.env, hookInput = null
|
|
|
211
211
|
if (hookInput && typeof hookInput === 'object') {
|
|
212
212
|
for (const key of ['session_id', 'sessionId', 'parent_session_id']) add(hookInput[key]);
|
|
213
213
|
}
|
|
214
|
-
// Tier 2 — process
|
|
215
|
-
|
|
214
|
+
// Tier 2 — select a native process id without importing the platform module
|
|
215
|
+
// into this hook-safe leaf or using shared repository markers as evidence.
|
|
216
|
+
const trim = (value) => typeof value === 'string' ? value.trim() : '';
|
|
217
|
+
const platform = trim(env?.SO_PLATFORM);
|
|
218
|
+
const claudeId = trim(env?.CLAUDE_CODE_SESSION_ID);
|
|
219
|
+
const codexId = trim(env?.CODEX_THREAD_ID);
|
|
220
|
+
if (platform === 'claude') add(claudeId);
|
|
221
|
+
else if (platform === 'codex') add(codexId);
|
|
222
|
+
else if (platform !== 'cursor' && platform !== 'pi') {
|
|
223
|
+
// Invalid/absent SO_PLATFORM is not an explicit choice (platform.mjs uses
|
|
224
|
+
// the same trimmed allowlist). Disagreement must never become a union.
|
|
225
|
+
if (!claudeId || !codexId || claudeId === codexId) add(claudeId || codexId);
|
|
226
|
+
}
|
|
216
227
|
|
|
217
228
|
return ids;
|
|
218
229
|
}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* Leaf module — no imports from siblings or parent.
|
|
6
6
|
*
|
|
7
7
|
* Exports: CURRENT_SESSION_SCHEMA_VERSION, SESSION_KEY_ALIASES,
|
|
8
|
-
* VALID_SESSION_TYPES, REQUIRED_FIELDS,
|
|
8
|
+
* VALID_SESSION_TYPES, VALID_SESSION_PROFILES, REQUIRED_FIELDS,
|
|
9
|
+
* AGENT_SUMMARY_FIELDS
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
// ---------------------------------------------------------------------------
|
|
@@ -82,6 +83,38 @@ export const SESSION_KEY_ALIASES = Object.freeze({
|
|
|
82
83
|
*/
|
|
83
84
|
export const VALID_SESSION_TYPES = Object.freeze(['feature', 'deep', 'housekeeping', 'unknown']);
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* CLOSED set of valid `session_profile` values — the SSOT for this enum
|
|
88
|
+
* (GitLab #1252). `scripts/lib/telemetry/schema.mjs` re-exports this constant
|
|
89
|
+
* rather than defining a second literal; `server/ingest/validate.mjs`
|
|
90
|
+
* `SESSION_PROFILES` is a deliberate MIRROR (the server tree must not import
|
|
91
|
+
* from `scripts/`) held in lockstep by `tests/telemetry/parity.test.mjs`.
|
|
92
|
+
*
|
|
93
|
+
* A profile names a WAVE-SHAPE variant on top of an UNCHANGED `session_type`:
|
|
94
|
+
* an ultradeep session is `session_type: "deep"` PLUS
|
|
95
|
+
* `session_profile: "ultradeep"` — never `session_type: "ultradeep"`.
|
|
96
|
+
* PRD: docs/prd/2026-09-06-ultradeep-session-profile.md. Writer:
|
|
97
|
+
* `commands/session.md` § "Argument alias: ultradeep" → STATE.md frontmatter
|
|
98
|
+
* `session-profile`; wave shape: `skills/session-plan/SKILL.md`.
|
|
99
|
+
*
|
|
100
|
+
* DELIBERATELY NOT A MEMBER of VALID_SESSION_TYPES above: that set is consumed
|
|
101
|
+
* by `scripts/lib/telemetry/schema.mjs` (an unlisted type → 'other') and by
|
|
102
|
+
* `scripts/lib/wave-sizing.mjs` (an unlisted type → TypeError), where a new
|
|
103
|
+
* MODE would be MISLABELLED rather than rejected. That reasoning is unchanged
|
|
104
|
+
* by the `unknown` member: `unknown` is the absence of a measurement, not a
|
|
105
|
+
* mode, and nothing dispatches on it.
|
|
106
|
+
*
|
|
107
|
+
* WHY A WHITELIST AND NOT A REGEX: the value is copied from repo-authored
|
|
108
|
+
* STATE.md frontmatter, i.e. it is the only usage-ping field whose VALUE is
|
|
109
|
+
* free text. Two Wave-1 reviewers reproduced the leak end-to-end (2026-09-06):
|
|
110
|
+
* `session-profile: client-acme-private-repo` travelled verbatim to the ingest
|
|
111
|
+
* server's `raw_json`. A shape regex does not close it — that string already
|
|
112
|
+
* passes any lowercase-and-hyphens pattern. Only an enumeration of names that
|
|
113
|
+
* are public BY CONSTRUCTION does. Adding a profile therefore means a reviewed
|
|
114
|
+
* edit HERE and in the server mirror.
|
|
115
|
+
*/
|
|
116
|
+
export const VALID_SESSION_PROFILES = Object.freeze(['ultradeep']);
|
|
117
|
+
|
|
85
118
|
/**
|
|
86
119
|
* Required fields for a schema_version=1 record. Validated by validateSession
|
|
87
120
|
* before any write reaches disk.
|
|
@@ -154,15 +187,9 @@ export const OPTIONAL_FIELDS = Object.freeze([
|
|
|
154
187
|
// into a note a human reads" ⊃ "schema-valid". A record missing it is a clean
|
|
155
188
|
// vault-mirror skip, NOT a malformed record.
|
|
156
189
|
'effectiveness',
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
// scripts/lib/telemetry/schema.mjs (an unlisted type -> 'other') and in
|
|
162
|
-
// scripts/lib/wave-sizing.mjs (an unlisted type -> TypeError), where a new
|
|
163
|
-
// MODE would be MISLABELLED rather than rejected. That reasoning is unchanged
|
|
164
|
-
// by the `unknown` member added above: `unknown` is the absence of a
|
|
165
|
-
// measurement, not a mode, and nothing dispatches on it. Every historical
|
|
166
|
-
// record lacking the field validates unchanged.
|
|
190
|
+
// `session_profile` — a WAVE-SHAPE variant on top of an unchanged
|
|
191
|
+
// `session_type`. Additive and optional on purpose: every historical record
|
|
192
|
+
// lacking the field validates unchanged. Value set + the full rationale for
|
|
193
|
+
// why it is NOT a VALID_SESSION_TYPES member: VALID_SESSION_PROFILES above.
|
|
167
194
|
'session_profile',
|
|
168
195
|
]);
|
|
@@ -168,6 +168,18 @@ export const PROBES = [
|
|
|
168
168
|
// vault-staleness-banner.mjs's header for the "one vocabulary, not two"
|
|
169
169
|
// rationale.
|
|
170
170
|
},
|
|
171
|
+
{
|
|
172
|
+
id: 'telemetry-flush-health',
|
|
173
|
+
spec: local('./telemetry-flush-health-banner.mjs'),
|
|
174
|
+
fn: 'checkTelemetryFlushHealth',
|
|
175
|
+
network: false,
|
|
176
|
+
args: ({ repoRoot }) => ({ repoRoot }),
|
|
177
|
+
// No custom render/severityOf: the probe returns `{severity:'warn',
|
|
178
|
+
// message}` — exactly the shape the module-level defaults below read. It
|
|
179
|
+
// reports the LAST `orchestrator.telemetry.flush` record when that record
|
|
180
|
+
// is a `sandbox:*` refusal (#1255); the refusal reached the ledger and
|
|
181
|
+
// nothing else before this entry existed.
|
|
182
|
+
},
|
|
171
183
|
{
|
|
172
184
|
id: 'ci-status',
|
|
173
185
|
spec: local('./ci-status-banner.mjs'),
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
import { getPlatform, getPluginRoot } from '../platform.mjs';
|
|
39
39
|
import { enumerateSurface } from '../sunset/walker.mjs';
|
|
40
40
|
import { readPluginVersionFromPackageJson } from '../bootstrap-lock-freshness.mjs';
|
|
41
|
+
import { VALID_SESSION_PROFILES } from '../session-schema/constants.mjs';
|
|
41
42
|
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
// Constants
|
|
@@ -124,8 +125,31 @@ const ROSTER_OTHER = 'other';
|
|
|
124
125
|
const MAX_NAME_LENGTH = 64;
|
|
125
126
|
const MAX_NAMES = 100;
|
|
126
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The two list bounds that are genuinely SHARED with the ingest server
|
|
130
|
+
* (`server/ingest/validate.mjs` `INGEST_LIST_BOUNDS` — MAX_LIST_ITEMS /
|
|
131
|
+
* MAX_LIST_ITEM_LEN). Exported so `tests/telemetry/parity.test.mjs` can compare
|
|
132
|
+
* the two trees instead of trusting two hand-typed numbers: a client that caps
|
|
133
|
+
* higher than the server emits pings the server 400s.
|
|
134
|
+
*
|
|
135
|
+
* The server's other five bounds (MAX_ANON_ID, MAX_SENT_AT, MAX_PLUGIN_VERSION,
|
|
136
|
+
* MAX_SESSION_TYPE, MAX_SESSION_PROFILE) are SERVER-ONLY BY DESIGN — they bound
|
|
137
|
+
* inputs from any client, including foreign or tampered ones, and have no client
|
|
138
|
+
* counterpart to keep in lockstep. Do not mirror them here.
|
|
139
|
+
*/
|
|
140
|
+
export const SHARED_LIST_BOUNDS = Object.freeze({
|
|
141
|
+
maxItems: MAX_NAMES,
|
|
142
|
+
maxItemLength: MAX_NAME_LENGTH,
|
|
143
|
+
});
|
|
144
|
+
|
|
127
145
|
/** Enum fallbacks. */
|
|
128
146
|
const VALID_PLATFORMS = Object.freeze(['claude', 'codex', 'cursor', 'pi']);
|
|
147
|
+
// DELIBERATE ASYMMETRY, not a drift: this is the TELEMETRY type set and omits
|
|
148
|
+
// `'unknown'`, which session-schema/constants.mjs VALID_SESSION_TYPES carries.
|
|
149
|
+
// There `unknown` is a storable ledger value; here it is a NORMALIZER FALLBACK
|
|
150
|
+
// (SESSION_TYPE_UNKNOWN below) that must never be reachable from the input set,
|
|
151
|
+
// or "we could not tell" would be indistinguishable from a measured type. Not
|
|
152
|
+
// unified with constants.mjs for exactly that reason.
|
|
129
153
|
const VALID_SESSION_TYPES = Object.freeze(['housekeeping', 'feature', 'deep']);
|
|
130
154
|
const PLATFORM_OTHER = 'other';
|
|
131
155
|
const SESSION_TYPE_OTHER = 'other';
|
|
@@ -145,27 +169,24 @@ const SESSION_TYPE_UNKNOWN = 'unknown';
|
|
|
145
169
|
/**
|
|
146
170
|
* CLOSED whitelist of PUBLIC session-profile names that may reach the wire.
|
|
147
171
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* repo-authored free text. Two independent Wave-1 reviewers reproduced the leak
|
|
152
|
-
* end-to-end (2026-09-06): `session-profile: client-acme-private-repo` travelled
|
|
153
|
-
* verbatim through `buildUsagePing` → `projectUsagePing` → the ingest server's
|
|
154
|
-
* `raw_json` column. A shape regex does NOT close it — that example string is
|
|
155
|
-
* already lowercase-and-hyphens and passes any such pattern. Only an
|
|
156
|
-
* enumeration of names that are public BY CONSTRUCTION does.
|
|
172
|
+
* RE-EXPORT, not a definition (GitLab #1252): the SSOT is
|
|
173
|
+
* `scripts/lib/session-schema/constants.mjs` `VALID_SESSION_PROFILES`, whose
|
|
174
|
+
* docblock carries the reproduced leak and the whitelist-over-regex rationale.
|
|
157
175
|
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
176
|
+
* Kept as a pure COMPATIBILITY SHIM, and measured as one: since 2026-09-07 no
|
|
177
|
+
* module in this repo imports the name from here (`rg -n VALID_SESSION_PROFILES
|
|
178
|
+
* scripts server hooks skills tests` — the two telemetry tests that used to now
|
|
179
|
+
* address constants.mjs). It stays because removing an export is a MAJOR change
|
|
180
|
+
* and this is a patch line; a deep importer outside the repo may still hold it.
|
|
181
|
+
* `tests/telemetry/schema.test.mjs` pins it with an IDENTITY assertion
|
|
182
|
+
* (`toBe`, not `toEqual`) so the shim can never quietly become a second
|
|
183
|
+
* definition of the whitelist.
|
|
164
184
|
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
185
|
+
* Adding a profile is a reviewed edit in constants.mjs AND in the server's
|
|
186
|
+
* mirror (`server/ingest/validate.mjs` `SESSION_PROFILES`) — the same two-tree
|
|
187
|
+
* contract `ACCEPTED_VERSIONS` already uses server-side.
|
|
167
188
|
*/
|
|
168
|
-
export
|
|
189
|
+
export { VALID_SESSION_PROFILES };
|
|
169
190
|
|
|
170
191
|
/**
|
|
171
192
|
* Closed sets for os/arch client-side normalization. A value outside the set —
|