session-orchestrator 3.22.0 → 3.24.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/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +446 -0
- package/README.md +22 -17
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -2
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +194 -25
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -7
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +120 -10
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +89 -9
- package/docs/session-config-template.md +38 -7
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +52 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +93 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +486 -19
- package/hooks/on-session-start.mjs +263 -12
- package/hooks/on-stop.mjs +392 -24
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +182 -21
- package/hooks/post-tool-batch-wave-signal.mjs +165 -42
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/hooks/subagent-telemetry.mjs +11 -26
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +31 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/emit-event.mjs +10 -2
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
- package/scripts/lib/build-live-signals.mjs +49 -27
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +87 -8
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +25 -10
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +517 -18
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +126 -0
- package/scripts/lib/session-close-backfill.mjs +427 -37
- package/scripts/lib/session-discovery.mjs +69 -5
- package/scripts/lib/session-end/phase-skip.mjs +38 -5
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +220 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +91 -16
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +333 -32
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +381 -141
- package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +889 -0
- package/scripts/materialize-wave-scope.mjs +228 -15
- package/scripts/mcp-server.sh +11 -2
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/parse-config.mjs +65 -0
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +29 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +305 -51
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +19 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +270 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +46 -3
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +20 -9
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +54 -17
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +5 -3
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +143 -22
- package/templates/_shared/journey-manifest.md +110 -0
- package/templates/_shared/rules/parallel-sessions.md +0 -77
|
@@ -6,8 +6,15 @@
|
|
|
6
6
|
* - Flow-style integer arrays (`[1, 2, 3]`)
|
|
7
7
|
* - Block-style sequences of mappings (issue #244), e.g. `docs-tasks:` with
|
|
8
8
|
* indented `- key: value` entries. Only one nesting level supported.
|
|
9
|
+
* - Single-line FLOW MAPPINGS as list items (`- { k: v, … }`) — hand-written
|
|
10
|
+
* but valid YAML, parsed into the same plain object a block item yields and
|
|
11
|
+
* re-emitted in block notation on the next serialize (#1111).
|
|
9
12
|
*
|
|
10
13
|
* That is the full grammar permitted by skills/_shared/state-ownership.md.
|
|
14
|
+
* A list item this grammar cannot represent (a flow SEQUENCE `- [a, b]`, or a
|
|
15
|
+
* flow mapping whose interior is malformed) is dropped from ITS OWN LIST and
|
|
16
|
+
* reported on `parseStateMd(...).warnings` — never escalated to a null
|
|
17
|
+
* document. See `parseBlockValue` for why that scoping is load-bearing.
|
|
11
18
|
*
|
|
12
19
|
* Never throws. Returns null for unparseable input rather than raising.
|
|
13
20
|
*
|
|
@@ -35,17 +42,28 @@ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
|
35
42
|
/**
|
|
36
43
|
* Parses a STATE.md file into frontmatter + body.
|
|
37
44
|
*
|
|
45
|
+
* `warnings` is OMITTED (not `null`, not `[]`) when the parse was clean, so a
|
|
46
|
+
* caller pinning the whole return with `toEqual({ frontmatter, body })` still
|
|
47
|
+
* matches and no consumer has to learn about it to keep working. When present
|
|
48
|
+
* it is a non-empty array of `{ key, index, reason }` records naming list items
|
|
49
|
+
* that were dropped from `frontmatter[key]` — `index` addresses the RAW source
|
|
50
|
+
* item position (dropped items included), so it stays quotable against the file
|
|
51
|
+
* even though the surviving array is shorter.
|
|
52
|
+
*
|
|
38
53
|
* @param {string} contents
|
|
39
|
-
* @returns {{frontmatter: object, body: string}|null}
|
|
54
|
+
* @returns {{frontmatter: object, body: string, warnings?: Array<{key: string, index: number, reason: string}>}|null}
|
|
40
55
|
*/
|
|
41
56
|
export function parseStateMd(contents) {
|
|
42
57
|
if (typeof contents !== 'string') return null;
|
|
43
58
|
const match = FRONTMATTER_RE.exec(contents);
|
|
44
59
|
if (!match) return null;
|
|
45
60
|
const [, fmText, body] = match;
|
|
46
|
-
const
|
|
61
|
+
const warnings = [];
|
|
62
|
+
const frontmatter = parseFrontmatter(fmText, warnings);
|
|
47
63
|
if (frontmatter === null) return null;
|
|
48
|
-
|
|
64
|
+
const parsed = { frontmatter, body: body.startsWith('\n') ? body.slice(1) : body };
|
|
65
|
+
if (warnings.length > 0) parsed.warnings = warnings;
|
|
66
|
+
return parsed;
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
/**
|
|
@@ -70,7 +88,7 @@ export function serializeStateMd({ frontmatter, body }) {
|
|
|
70
88
|
return `---\n${fmLines.join('\n')}\n---\n${bodyOut}`;
|
|
71
89
|
}
|
|
72
90
|
|
|
73
|
-
function parseFrontmatter(text) {
|
|
91
|
+
function parseFrontmatter(text, warnings) {
|
|
74
92
|
const out = {};
|
|
75
93
|
const lines = text.split(/\r?\n/);
|
|
76
94
|
let i = 0;
|
|
@@ -87,7 +105,7 @@ function parseFrontmatter(text) {
|
|
|
87
105
|
if (key === '') return null;
|
|
88
106
|
const valuePart = rstripped.slice(idx + 1).trim();
|
|
89
107
|
if (valuePart === '') {
|
|
90
|
-
const result = parseBlockValue(lines, i + 1);
|
|
108
|
+
const result = parseBlockValue(lines, i + 1, key, warnings);
|
|
91
109
|
if (result === null) return null;
|
|
92
110
|
out[key] = result.value;
|
|
93
111
|
i = result.nextIndex;
|
|
@@ -106,8 +124,27 @@ function parseFrontmatter(text) {
|
|
|
106
124
|
* no body) and `nextIndex === start` so the caller resumes at `start`.
|
|
107
125
|
* - `value === [...]` means a block sequence was consumed.
|
|
108
126
|
* Returns `null` on malformed block syntax.
|
|
127
|
+
*
|
|
128
|
+
* A list item that opens a YAML FLOW collection is resolved PER ITEM, never per
|
|
129
|
+
* document:
|
|
130
|
+
* - `- { id: m-1, task: "x", wave: 1, status: b }` parses into exactly the
|
|
131
|
+
* object the equivalent block item yields (and serializes back as block
|
|
132
|
+
* notation). Splitting it at the first colon — the #1111 bug, measured
|
|
133
|
+
* 2026-08-24 @ f0766e1 as `keys: ["{ id"] · status: undefined` — is what
|
|
134
|
+
* this must never do again.
|
|
135
|
+
* - Anything else flow-shaped (`- [a, b]`, or a `{ … }` whose interior is
|
|
136
|
+
* malformed) is dropped from THIS list with a `warnings` record. It cannot
|
|
137
|
+
* be kept: a non-mapping item makes `isBlockSeqOfMappings` false for the
|
|
138
|
+
* whole array, and the serializer would then emit the entire list through
|
|
139
|
+
* `serializeScalar` as `[object Object]`.
|
|
140
|
+
*
|
|
141
|
+
* Escalating either case to `null` for the WHOLE document — the first #1111 fix
|
|
142
|
+
* — is the failure this scoping exists to prevent: every mutator in this family
|
|
143
|
+
* opens with `parseStateMd(contents); if (parsed === null) return contents;`, so
|
|
144
|
+
* one hand-written item silently turned every STATE.md write into a no-op that
|
|
145
|
+
* reported success. Silent-wrong must not be traded for silent-absent.
|
|
109
146
|
*/
|
|
110
|
-
function parseBlockValue(lines, start) {
|
|
147
|
+
function parseBlockValue(lines, start, key, warnings) {
|
|
111
148
|
let i = start;
|
|
112
149
|
while (i < lines.length) {
|
|
113
150
|
const rstripped = lines[i].replace(/\s+$/, '');
|
|
@@ -124,6 +161,7 @@ function parseBlockValue(lines, start) {
|
|
|
124
161
|
const indent = bulletMatch[1];
|
|
125
162
|
const contIndent = indent + ' ';
|
|
126
163
|
const entries = [];
|
|
164
|
+
let itemIndex = 0;
|
|
127
165
|
while (i < lines.length) {
|
|
128
166
|
const rstripped = lines[i].replace(/\s+$/, '');
|
|
129
167
|
if (rstripped === '' || /^\s*#/.test(rstripped)) {
|
|
@@ -132,12 +170,26 @@ function parseBlockValue(lines, start) {
|
|
|
132
170
|
}
|
|
133
171
|
if (!rstripped.startsWith(indent + '- ')) break;
|
|
134
172
|
const firstBody = rstripped.slice(indent.length + 2);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
173
|
+
// `entry === null` marks an item this grammar cannot represent: its
|
|
174
|
+
// continuation lines are still consumed below (so the walk stays aligned),
|
|
175
|
+
// but nothing is pushed onto `entries`.
|
|
176
|
+
let entry;
|
|
177
|
+
if (firstBody.startsWith('{')) {
|
|
178
|
+
entry = parseFlowMapping(firstBody);
|
|
179
|
+
if (entry === null) {
|
|
180
|
+
warnings.push({ key, index: itemIndex, reason: 'malformed-flow-mapping' });
|
|
181
|
+
}
|
|
182
|
+
} else if (firstBody.startsWith('[')) {
|
|
183
|
+
entry = null;
|
|
184
|
+
warnings.push({ key, index: itemIndex, reason: 'flow-sequence-item' });
|
|
185
|
+
} else {
|
|
186
|
+
const firstColon = firstBody.indexOf(':');
|
|
187
|
+
if (firstColon === -1) return null;
|
|
188
|
+
const firstKey = firstBody.slice(0, firstColon).trim();
|
|
189
|
+
if (firstKey === '') return null;
|
|
190
|
+
entry = {};
|
|
191
|
+
entry[firstKey] = parseScalar(firstBody.slice(firstColon + 1).trim());
|
|
192
|
+
}
|
|
141
193
|
i++;
|
|
142
194
|
while (i < lines.length) {
|
|
143
195
|
const inner = lines[i].replace(/\s+$/, '');
|
|
@@ -150,16 +202,93 @@ function parseBlockValue(lines, start) {
|
|
|
150
202
|
if (/^\s/.test(body)) return null;
|
|
151
203
|
const colon = body.indexOf(':');
|
|
152
204
|
if (colon === -1) return null;
|
|
153
|
-
const
|
|
154
|
-
if (
|
|
155
|
-
entry[
|
|
205
|
+
const contKey = body.slice(0, colon).trim();
|
|
206
|
+
if (contKey === '') return null;
|
|
207
|
+
if (entry !== null) entry[contKey] = parseScalar(body.slice(colon + 1).trim());
|
|
156
208
|
i++;
|
|
157
209
|
}
|
|
158
|
-
entries.push(entry);
|
|
210
|
+
if (entry !== null) entries.push(entry);
|
|
211
|
+
itemIndex++;
|
|
159
212
|
}
|
|
160
213
|
return { value: entries, nextIndex: i };
|
|
161
214
|
}
|
|
162
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Splits a flow-collection interior on its TOP-LEVEL commas — the ones outside
|
|
218
|
+
* quotes and outside any nested `{}`/`[]`. A naive `split(',')` would cut
|
|
219
|
+
* `task: "a, b"` in half, which is the same class of first-separator mistake
|
|
220
|
+
* that produced the `{ id` key in the first place.
|
|
221
|
+
*
|
|
222
|
+
* @param {string} inner
|
|
223
|
+
* @returns {string[]}
|
|
224
|
+
*/
|
|
225
|
+
function splitFlowSegments(inner) {
|
|
226
|
+
const segments = [];
|
|
227
|
+
let depth = 0;
|
|
228
|
+
let quote = null;
|
|
229
|
+
let current = '';
|
|
230
|
+
for (let i = 0; i < inner.length; i++) {
|
|
231
|
+
const ch = inner[i];
|
|
232
|
+
if (quote !== null) {
|
|
233
|
+
current += ch;
|
|
234
|
+
if (ch === '\\' && quote === '"') {
|
|
235
|
+
current += inner[++i] ?? '';
|
|
236
|
+
} else if (ch === quote) {
|
|
237
|
+
quote = null;
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (ch === '"' || ch === "'") quote = ch;
|
|
242
|
+
else if (ch === '{' || ch === '[') depth++;
|
|
243
|
+
else if (ch === '}' || ch === ']') depth--;
|
|
244
|
+
else if (ch === ',' && depth === 0) {
|
|
245
|
+
segments.push(current);
|
|
246
|
+
current = '';
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
current += ch;
|
|
250
|
+
}
|
|
251
|
+
segments.push(current);
|
|
252
|
+
return segments;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parses a SINGLE-LINE YAML flow mapping (`{ k: v, k2: "v, 2" }`) into a plain
|
|
257
|
+
* object, reusing `parseScalar` for each value so a flow item and the equivalent
|
|
258
|
+
* block item yield byte-identical results.
|
|
259
|
+
*
|
|
260
|
+
* Returns `null` when `raw` is not a well-formed single-line flow mapping —
|
|
261
|
+
* unterminated, an empty segment, or a segment with no `:`. Callers report that
|
|
262
|
+
* as a dropped item; nothing here throws.
|
|
263
|
+
*
|
|
264
|
+
* KNOWN CEILING: a NESTED flow mapping value (`{ id: m-1, meta: { a: 1 } }`)
|
|
265
|
+
* keeps `{ a: 1 }` as a STRING rather than an object — this subset has no
|
|
266
|
+
* nested-mapping representation and the serializer has no way to emit one. Flat
|
|
267
|
+
* flow mappings are the whole observed population (hand-written `mission-status`
|
|
268
|
+
* / `docs-tasks` items). Revisit if a nested flow value ever appears in a real
|
|
269
|
+
* STATE.md.
|
|
270
|
+
*
|
|
271
|
+
* @param {string} raw
|
|
272
|
+
* @returns {object|null}
|
|
273
|
+
*/
|
|
274
|
+
function parseFlowMapping(raw) {
|
|
275
|
+
const trimmed = raw.trim();
|
|
276
|
+
if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return null;
|
|
277
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
278
|
+
const out = {};
|
|
279
|
+
if (inner === '') return out;
|
|
280
|
+
for (const segment of splitFlowSegments(inner)) {
|
|
281
|
+
const part = segment.trim();
|
|
282
|
+
if (part === '') return null;
|
|
283
|
+
const colon = part.indexOf(':');
|
|
284
|
+
if (colon === -1) return null;
|
|
285
|
+
const key = part.slice(0, colon).trim();
|
|
286
|
+
if (key === '') return null;
|
|
287
|
+
out[key] = parseScalar(part.slice(colon + 1).trim());
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
163
292
|
function parseScalar(raw) {
|
|
164
293
|
if (raw === '' || raw === 'null' || raw === '~') return null;
|
|
165
294
|
if (raw === 'true') return true;
|
package/scripts/lib/state-md.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* STATE.md helpers (issue #184).
|
|
3
3
|
*
|
|
4
|
-
* Thin barrel — re-exports
|
|
4
|
+
* Thin barrel — re-exports every public symbol of the split submodules.
|
|
5
5
|
* Implementation lives in scripts/lib/state-md/*.mjs.
|
|
6
6
|
*
|
|
7
7
|
* @see scripts/lib/state-md/yaml-parser.mjs parseStateMd, serializeStateMd
|
|
8
8
|
* @see scripts/lib/state-md/frontmatter-mutators.mjs touchUpdatedField, updateFrontmatterFields
|
|
9
9
|
* @see scripts/lib/state-md/body-sections.mjs readCurrentTask, appendDeviation, markExpressPathComplete, appendWhatNotToRetry, readWhatNotToRetry, readOpenQuestions, appendOpenQuestion, markOpenQuestionAnswered
|
|
10
|
-
* @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, writeMissionStatus, setMissionStatus, readMissionStatus
|
|
10
|
+
* @see scripts/lib/state-md/mission-status.mjs parseMissionStatus, parseMissionStatusStrict, MISSION_STATUS_VALUES, writeMissionStatus, setMissionStatus, setMissionStatusDetailed, readMissionStatus, recoverFrontmatterMissionStatusDetailed, writeMissionStatusOnDisk, setMissionStatusOnDisk
|
|
11
11
|
* @see scripts/lib/state-md/recommendations.mjs parseRecommendations
|
|
12
12
|
*/
|
|
13
13
|
|
|
@@ -43,11 +43,21 @@ export {
|
|
|
43
43
|
|
|
44
44
|
export {
|
|
45
45
|
parseMissionStatus,
|
|
46
|
+
// The strict reader and the status vocabulary are part of the same public
|
|
47
|
+
// surface as `parseMissionStatus` (#1111) — a consumer that imports from this
|
|
48
|
+
// barrel (the documented entry point) reached neither until this re-export
|
|
49
|
+
// landed: a static `import { parseMissionStatusStrict } from '.../state-md.mjs'`
|
|
50
|
+
// failed at link time, and the `await import()` form — the one hooks and
|
|
51
|
+
// lazy loaders use — yielded `undefined` and failed only at the call.
|
|
52
|
+
parseMissionStatusStrict,
|
|
53
|
+
MISSION_STATUS_VALUES,
|
|
46
54
|
writeMissionStatus,
|
|
47
55
|
setMissionStatus,
|
|
48
56
|
readMissionStatus,
|
|
49
57
|
writeMissionStatusOnDisk,
|
|
50
58
|
setMissionStatusOnDisk,
|
|
59
|
+
recoverFrontmatterMissionStatusDetailed,
|
|
60
|
+
setMissionStatusDetailed,
|
|
51
61
|
} from './state-md/mission-status.mjs';
|
|
52
62
|
|
|
53
63
|
export { parseRecommendations } from './state-md/recommendations.mjs';
|
|
@@ -220,6 +220,54 @@ export function filterRosterNames(names, rosterSet) {
|
|
|
220
220
|
return deduped.slice(0, MAX_NAMES);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
+
/**
|
|
224
|
+
* Classify a single raw invocation name into the skills or commands bucket.
|
|
225
|
+
*
|
|
226
|
+
* The Skill tool surfaces slash-commands that have NO backing `skills/` directory
|
|
227
|
+
* under the same plugin-prefixed name form as real skills (e.g.
|
|
228
|
+
* `session-orchestrator:session` for `commands/session.md`). `roster.skills` holds
|
|
229
|
+
* PREFIXED names, `roster.commands` holds BARE ones — so a prefixed command name
|
|
230
|
+
* matches neither set and used to be bucketed to 'other' (GitLab #1189: commands
|
|
231
|
+
* was `[]` in 345/345 usage pings).
|
|
232
|
+
*
|
|
233
|
+
* Decision order (skill roster wins on a spelling collision such as
|
|
234
|
+
* `memory-cleanup`, which is both a skill dir and a command file):
|
|
235
|
+
* 1. name in rosterSkills → {kind:'skill', name} (verbatim)
|
|
236
|
+
* 2. name is PREFIXED and its bare form is in rosterCommands
|
|
237
|
+
* → {kind:'command', bare}
|
|
238
|
+
* 3. otherwise → {kind:'skill', name} (verbatim)
|
|
239
|
+
*
|
|
240
|
+
* Case 2 REQUIRES the plugin prefix (session-reviewer W2 finding, 2026-09-02).
|
|
241
|
+
* Without that requirement, a BARE third-party or personal skill name that
|
|
242
|
+
* happens to collide with one of our shipped command names (`test`, `close`,
|
|
243
|
+
* `go`, `release`, `portfolio` are all plausible foreign skill names) would be
|
|
244
|
+
* recorded as OUR command — a data-integrity defect, and the same rule made
|
|
245
|
+
* `memory-cleanup` reach BOTH buckets in one ping (bare → commands, prefixed →
|
|
246
|
+
* skills), double-counting a single surface. Requiring the prefix removes both:
|
|
247
|
+
* a bare arrival can only ever be a skill-path name, so an unknown one becomes
|
|
248
|
+
* 'other'. Cost: the one genuine bare `memory-cleanup` record is recorded as
|
|
249
|
+
* 'other' rather than as the command.
|
|
250
|
+
*
|
|
251
|
+
* Case 3 keeps foreign/third-party names on the SKILLS path only, where
|
|
252
|
+
* filterRosterNames projects them to the opaque token 'other' — an unclassified
|
|
253
|
+
* name is never duplicated into `commands`, preserving the privacy invariant.
|
|
254
|
+
*
|
|
255
|
+
* @param {unknown} name raw invocation name
|
|
256
|
+
* @param {Set<string>} rosterSkills prefixed shipped-skill names
|
|
257
|
+
* @param {Set<string>} rosterCommands bare shipped-command names
|
|
258
|
+
* @returns {{kind: 'skill'|'command', name: unknown}}
|
|
259
|
+
*/
|
|
260
|
+
export function classifyInvocationName(name, rosterSkills, rosterCommands) {
|
|
261
|
+
const skills = rosterSkills instanceof Set ? rosterSkills : new Set();
|
|
262
|
+
const commands = rosterCommands instanceof Set ? rosterCommands : new Set();
|
|
263
|
+
if (typeof name !== 'string') return { kind: 'skill', name };
|
|
264
|
+
if (skills.has(name)) return { kind: 'skill', name };
|
|
265
|
+
if (!name.startsWith(SKILL_PREFIX)) return { kind: 'skill', name };
|
|
266
|
+
const bare = name.slice(SKILL_PREFIX.length);
|
|
267
|
+
if (commands.has(bare)) return { kind: 'command', name: bare };
|
|
268
|
+
return { kind: 'skill', name };
|
|
269
|
+
}
|
|
270
|
+
|
|
223
271
|
// ---------------------------------------------------------------------------
|
|
224
272
|
// Duration bucketing
|
|
225
273
|
// ---------------------------------------------------------------------------
|
|
@@ -331,12 +379,12 @@ function distinctField(records, field) {
|
|
|
331
379
|
* whitelist-clean but does NOT carry `anon_id` — the caller sets it via
|
|
332
380
|
* ensureAnonId (anon-id.mjs), keeping ID rotation isolated there.
|
|
333
381
|
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* in without a signature change). Both are
|
|
338
|
-
* become "other" — deduped, sorted, and
|
|
339
|
-
* decision).
|
|
382
|
+
* The distinct `.skill` and `.command` values of `skillInvocations` go through a
|
|
383
|
+
* single classifyInvocationName pass that routes each name to skills or commands
|
|
384
|
+
* (the `.command` field carries nothing today; it is honored so a future direct
|
|
385
|
+
* command-telemetry stream feeds in without a signature change). Both buckets are
|
|
386
|
+
* then roster-filtered — off-roster names become "other" — deduped, sorted, and
|
|
387
|
+
* capped. No frequencies are recorded (v1 decision).
|
|
340
388
|
*
|
|
341
389
|
* @param {{
|
|
342
390
|
* sessionRecord: object,
|
|
@@ -362,8 +410,26 @@ export function buildUsagePing({
|
|
|
362
410
|
const rosterSkills = rst?.skills instanceof Set ? rst.skills : new Set();
|
|
363
411
|
const rosterCommands = rst?.commands instanceof Set ? rst.commands : new Set();
|
|
364
412
|
|
|
365
|
-
|
|
366
|
-
|
|
413
|
+
// ONE classification pass over the union of both producer fields: today only
|
|
414
|
+
// `.skill` is written by hooks/skill-invocation-telemetry.mjs, `.command` is
|
|
415
|
+
// kept for the forward-compat producer documented above.
|
|
416
|
+
//
|
|
417
|
+
// `.command` records carry the BARE name, and classifyInvocationName requires
|
|
418
|
+
// the plugin prefix before it will route anything to the commands bucket (see
|
|
419
|
+
// its doc comment). So a `.command` name is normalized to the prefixed form
|
|
420
|
+
// first — the field itself is the "this is one of ours" signal that a bare
|
|
421
|
+
// `.skill` arrival lacks. Without this the forward-compat producer would be
|
|
422
|
+
// wired but dead: every record it writes would silently become 'other'.
|
|
423
|
+
const skillNames = [];
|
|
424
|
+
const commandNames = [];
|
|
425
|
+
const rawNames = [
|
|
426
|
+
...distinctField(invocations, 'skill'),
|
|
427
|
+
...distinctField(invocations, 'command').map((n) => (n.startsWith(SKILL_PREFIX) ? n : `${SKILL_PREFIX}${n}`)),
|
|
428
|
+
];
|
|
429
|
+
for (const raw of rawNames) {
|
|
430
|
+
const { kind, name } = classifyInvocationName(raw, rosterSkills, rosterCommands);
|
|
431
|
+
(kind === 'command' ? commandNames : skillNames).push(name);
|
|
432
|
+
}
|
|
367
433
|
|
|
368
434
|
return {
|
|
369
435
|
record_kind: 'usage-ping',
|
|
@@ -39,6 +39,7 @@ import { ensureAnonId } from './anon-id.mjs';
|
|
|
39
39
|
import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
|
|
40
40
|
import { loadOwnerConfig } from '../owner-yaml.mjs';
|
|
41
41
|
import { readJsonlFile } from '../io.mjs';
|
|
42
|
+
import { readCanonicalSessions } from '../sessions-canonical.mjs';
|
|
42
43
|
|
|
43
44
|
// ---------------------------------------------------------------------------
|
|
44
45
|
// Constants
|
|
@@ -93,15 +94,51 @@ function defaultSender({ env, timeoutMs }) {
|
|
|
93
94
|
// Batch build
|
|
94
95
|
// ---------------------------------------------------------------------------
|
|
95
96
|
|
|
97
|
+
/**
|
|
98
|
+
* The canonical (#1167-deduplicated) session record most recently WRITTEN to
|
|
99
|
+
* the ledger — ranked by `completed_at` (falling back to `started_at` when
|
|
100
|
+
* absent), the closest analogue to "the last line of the file" once the reader
|
|
101
|
+
* no longer trusts append order.
|
|
102
|
+
*
|
|
103
|
+
* `readCanonicalSessions` reorders its output to "first appearance of each
|
|
104
|
+
* surviving id" (see sessions-canonical.mjs's own docstring) — it is NOT
|
|
105
|
+
* append order — so a raw `records[records.length - 1]` (the pre-#1186 read)
|
|
106
|
+
* silently picks the WRONG session once a `session_id` duplicate or a
|
|
107
|
+
* `supersedes` collapse reshuffles the array. A record with neither timestamp
|
|
108
|
+
* sorts last and is never chosen over a dated one.
|
|
109
|
+
*
|
|
110
|
+
* @param {Array<object>} records — canonical session records.
|
|
111
|
+
* @returns {object|null}
|
|
112
|
+
*/
|
|
113
|
+
function mostRecentSession(records) {
|
|
114
|
+
let best = null;
|
|
115
|
+
let bestTs = '';
|
|
116
|
+
for (const rec of records) {
|
|
117
|
+
if (!rec || typeof rec !== 'object') continue;
|
|
118
|
+
const ts =
|
|
119
|
+
typeof rec.completed_at === 'string' && rec.completed_at
|
|
120
|
+
? rec.completed_at
|
|
121
|
+
: typeof rec.started_at === 'string'
|
|
122
|
+
? rec.started_at
|
|
123
|
+
: '';
|
|
124
|
+
if (ts && ts > bestTs) {
|
|
125
|
+
best = rec;
|
|
126
|
+
bestTs = ts;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return best;
|
|
130
|
+
}
|
|
131
|
+
|
|
96
132
|
/**
|
|
97
133
|
* Build ONE whitelist-projected usage-ping record from the local JSONL streams.
|
|
98
134
|
*
|
|
99
|
-
* Reads `<metricsDir>/sessions.jsonl`
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* the
|
|
135
|
+
* Reads `<metricsDir>/sessions.jsonl` (via `readCanonicalSessions`, #1186 — the
|
|
136
|
+
* #1167 newest-wins-per-`session_id` / `supersedes` collapse) +
|
|
137
|
+
* `<metricsDir>/skill-invocations.jsonl`. The most-recently-written CANONICAL
|
|
138
|
+
* session record (`mostRecentSession`, above) defines the session window:
|
|
139
|
+
* skill-invocations whose `timestamp >=` its `started_at` are included. When no
|
|
140
|
+
* session record exists, the ping falls back to `session_type: 'other'`,
|
|
141
|
+
* `duration_bucket: '<15m'`, and the invocations of the last 24 hours.
|
|
105
142
|
*
|
|
106
143
|
* anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
|
|
107
144
|
* telemetry.json record; a created/rotated ID is persisted via
|
|
@@ -137,10 +174,10 @@ export function buildBatch({
|
|
|
137
174
|
const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
|
|
138
175
|
const nowIso = now || new Date().toISOString();
|
|
139
176
|
|
|
140
|
-
const sessions =
|
|
177
|
+
const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
|
|
141
178
|
const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
|
|
142
179
|
|
|
143
|
-
const sessionRecord = sessions
|
|
180
|
+
const sessionRecord = mostRecentSession(sessions);
|
|
144
181
|
|
|
145
182
|
let windowInvocations;
|
|
146
183
|
let sessionForPing;
|
|
@@ -287,25 +324,63 @@ export async function flush({
|
|
|
287
324
|
// ---------------------------------------------------------------------------
|
|
288
325
|
|
|
289
326
|
/**
|
|
290
|
-
* Whether a daily-fallback flush is due
|
|
291
|
-
* have passed since the last successful flush
|
|
292
|
-
* counts as
|
|
327
|
+
* Whether a daily-fallback flush is due. Two independent ways to be due, both
|
|
328
|
+
* requiring that more than 24h have passed since the last successful flush
|
|
329
|
+
* (never-flushed counts as infinitely stale):
|
|
330
|
+
*
|
|
331
|
+
* (a) RETRY — the offline queue is non-empty: earlier sends failed and the
|
|
332
|
+
* backlog deserves another attempt.
|
|
333
|
+
* (b) CATCH-UP — the queue is empty, but `<metricsDir>/sessions.jsonl` ends on
|
|
334
|
+
* a record whose `completed_at` is NEWER than `last_flush_at`: a session
|
|
335
|
+
* closed since the last successful send and produced no ping.
|
|
336
|
+
*
|
|
337
|
+
* (b) is the reason this predicate exists at all. While it was queue-only, the
|
|
338
|
+
* "daily fallback" could ONLY re-send what had already failed to send — it could
|
|
339
|
+
* never originate a ping. Measured 2026-08-23 (#1138): 588 session closes across
|
|
340
|
+
* 13 repos produced 82 ingest records (~14%), because the ONE writer of the
|
|
341
|
+
* queue is a failed `flush()`, and a `flush()` that never runs never fails.
|
|
342
|
+
*
|
|
343
|
+
* Cheap in the common case: the staleness gate is checked FIRST (one small
|
|
344
|
+
* telemetry.json read), so a host that flushed within the last 24h returns
|
|
345
|
+
* before touching either the queue or the session ledger.
|
|
346
|
+
*
|
|
347
|
+
* Never throws.
|
|
293
348
|
*
|
|
294
349
|
* @param {object} [opts]
|
|
295
350
|
* @param {string} [opts.statePath] telemetry.json path override.
|
|
296
351
|
* @param {string} [opts.queuePath] queue path override.
|
|
352
|
+
* @param {string} [opts.metricsDir] Metrics dir for the (b) catch-up probe
|
|
353
|
+
* (default `<cwd>/.orchestrator/metrics`).
|
|
297
354
|
* @param {number} [opts.now] Reference time in epoch-ms (default Date.now()).
|
|
298
355
|
* @returns {boolean}
|
|
299
356
|
*/
|
|
300
|
-
export function shouldDailyFlush({ statePath, queuePath, now = Date.now() } = {}) {
|
|
357
|
+
export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.now() } = {}) {
|
|
301
358
|
try {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
359
|
+
// Staleness gate — shared by BOTH disjuncts, so it runs first and short-
|
|
360
|
+
// circuits the two file probes below on every fresh host.
|
|
305
361
|
const { record } = readTelemetryState({ path: statePath });
|
|
306
362
|
const raw = record?.last_flush_at;
|
|
307
363
|
const lastMs = typeof raw === 'string' && !Number.isNaN(Date.parse(raw)) ? Date.parse(raw) : 0;
|
|
308
|
-
|
|
364
|
+
if (now - lastMs <= DAILY_FLUSH_MS) return false;
|
|
365
|
+
|
|
366
|
+
// (a) Retry an existing backlog.
|
|
367
|
+
const { count } = queueStats({ path: queuePath });
|
|
368
|
+
if (count > 0) return true;
|
|
369
|
+
|
|
370
|
+
// (b) Catch-up: a session completed after the last successful flush.
|
|
371
|
+
// Reuses buildBatch's reader (#1186: readCanonicalSessions +
|
|
372
|
+
// mostRecentSession) — same file, same #1167 dedupe, no second parser.
|
|
373
|
+
//
|
|
374
|
+
// Deliberate simplification (named ceiling): this parses the WHOLE
|
|
375
|
+
// sessions.jsonl to look at its last record. At the observed ledger size
|
|
376
|
+
// (271 records / 292 KB in this repo, 2026-08-23) that is sub-millisecond
|
|
377
|
+
// and it only runs once the 24h gate above has already passed. Revisit
|
|
378
|
+
// with a tail-read if any repo's sessions.jsonl passes ~10 MB.
|
|
379
|
+
const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
|
|
380
|
+
const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
|
|
381
|
+
const last = mostRecentSession(sessions);
|
|
382
|
+
const completedMs = Date.parse(last?.completed_at);
|
|
383
|
+
return !Number.isNaN(completedMs) && completedMs > lastMs;
|
|
309
384
|
} catch {
|
|
310
385
|
return false;
|
|
311
386
|
}
|
|
@@ -19,6 +19,7 @@ import { appendFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
|
19
19
|
import path from 'node:path';
|
|
20
20
|
|
|
21
21
|
import { findProjectRoot } from '../common.mjs';
|
|
22
|
+
import { stampEventSchemaVersion, validateEventRecord } from '../events-schema.mjs';
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* Path FRAGMENT joined against a resolved repo root at write time — NOT a
|
|
@@ -58,11 +59,22 @@ export function emit(eventType, payload = {}, { repoRoot } = {}) {
|
|
|
58
59
|
const eventsPath = path.join(repoRoot || findProjectRoot(), ...EVENTS_REL);
|
|
59
60
|
const dir = path.dirname(eventsPath);
|
|
60
61
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
61
|
-
|
|
62
|
+
// `stampEventSchemaVersion()` rather than an inline `schema_version:` —
|
|
63
|
+
// one stamper for the whole ledger (#1177). It is additive (stamps only
|
|
64
|
+
// when absent), so a payload that carries its own version still wins, and
|
|
65
|
+
// it is pure (no fs), so the sync write path below is unaffected.
|
|
66
|
+
const record = stampEventSchemaVersion({
|
|
62
67
|
event: eventType,
|
|
63
68
|
timestamp: new Date().toISOString(),
|
|
64
69
|
...payload,
|
|
65
|
-
};
|
|
70
|
+
});
|
|
71
|
+
// Same schema contract as emitEvent() (#1177), enforced here too because
|
|
72
|
+
// this is the ONE remaining raw writer of events.jsonl. Kept synchronous on
|
|
73
|
+
// purpose — withTelemetry() wraps sync layout code; validateEventRecord() is
|
|
74
|
+
// pure (no fs), so conformance costs no async hop. An invalid record is
|
|
75
|
+
// DROPPED rather than written: telemetry is best-effort by contract, and a
|
|
76
|
+
// malformed line would outlive this process in the shared ledger.
|
|
77
|
+
if (!validateEventRecord(record).valid) return;
|
|
66
78
|
appendFileSync(eventsPath, JSON.stringify(record) + '\n');
|
|
67
79
|
} catch {
|
|
68
80
|
// Best-effort — swallow all errors. Telemetry must not block layout.
|
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
import yaml from 'js-yaml';
|
|
11
11
|
import { ALLOWED_MODEL_ALIASES, MODEL_ID_RE } from '../agent-frontmatter.mjs';
|
|
12
12
|
import { extractInitialFrontmatter } from './frontmatter-block.mjs';
|
|
13
|
+
import { parseFrontmatterDescription } from '../description-surface.mjs';
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
|
|
@@ -136,6 +137,57 @@ function parseToolsValue(toolsVal) {
|
|
|
136
137
|
return v.split(',').map((t) => t.trim()).filter(Boolean);
|
|
137
138
|
}
|
|
138
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Detect a `description:` whose YAML-PARSED value is a silently truncated
|
|
142
|
+
* PREFIX of what the file actually says (#1029).
|
|
143
|
+
*
|
|
144
|
+
* An unquoted (plain) YAML scalar ends at the first ` #`: everything from there
|
|
145
|
+
* on is a comment. The block still parses, so every other rule in this gate
|
|
146
|
+
* stays green while the agent picker receives a stub — `agents/eval-judge.md`
|
|
147
|
+
* shipped 51 of 1,180 characters (96% dropped, measured 2026-08-24) and nothing
|
|
148
|
+
* complained. This is the concrete case the Check-6 preamble describes in prose.
|
|
149
|
+
*
|
|
150
|
+
* Detected by COMPARISON, never by pattern: the parsed value must be a strict
|
|
151
|
+
* prefix of the raw scalar AND the dropped tail must open a comment. A
|
|
152
|
+
* legitimately quoted description whose text merely contains `#` parses in full
|
|
153
|
+
* (parsed === raw, no length gap) and is never flagged; a quoted description
|
|
154
|
+
* carrying YAML escapes diverges from its raw form and fails the prefix test.
|
|
155
|
+
*
|
|
156
|
+
* Raw extraction is delegated to `parseFrontmatterDescription` (the sibling
|
|
157
|
+
* measurement probe already owns it, block scalars and quote-stripping included)
|
|
158
|
+
* rather than re-derived from a fourth regex here.
|
|
159
|
+
*
|
|
160
|
+
* @param {string} content - the agent file's raw content
|
|
161
|
+
* @param {Record<string, unknown>} data - the parsed frontmatter mapping
|
|
162
|
+
* @param {string} frontmatter - the frontmatter block's inner text
|
|
163
|
+
* @returns {{ parsedLength: number, rawLength: number } | null} null when intact
|
|
164
|
+
*/
|
|
165
|
+
function detectDescriptionTruncation(content, data, frontmatter) {
|
|
166
|
+
if (!hasField(frontmatter, 'description')) return null;
|
|
167
|
+
|
|
168
|
+
// A `description:` key whose WHOLE value is a comment (`description: #803 …`)
|
|
169
|
+
// parses to null — a 0-character description, the same defect at 100% loss.
|
|
170
|
+
const parsedValue =
|
|
171
|
+
typeof data?.description === 'string'
|
|
172
|
+
? data.description
|
|
173
|
+
: data?.description === null || data?.description === undefined
|
|
174
|
+
? ''
|
|
175
|
+
: null;
|
|
176
|
+
if (parsedValue === null) return null;
|
|
177
|
+
|
|
178
|
+
const rawField = parseFrontmatterDescription(content);
|
|
179
|
+
// Block scalars carry no comment semantics and are rejected outright by the
|
|
180
|
+
// inline-string rule below — not this rule's business.
|
|
181
|
+
if (!rawField || rawField.isBlockScalar) return null;
|
|
182
|
+
|
|
183
|
+
const raw = rawField.raw;
|
|
184
|
+
if (raw.length <= parsedValue.length) return null;
|
|
185
|
+
if (!raw.startsWith(parsedValue)) return null;
|
|
186
|
+
if (!/(^|\s)#/.test(raw.slice(parsedValue.length))) return null;
|
|
187
|
+
|
|
188
|
+
return { parsedLength: parsedValue.length, rawLength: raw.length };
|
|
189
|
+
}
|
|
190
|
+
|
|
139
191
|
// ============================================================================
|
|
140
192
|
// Check 6: Agent .md files have valid YAML frontmatter
|
|
141
193
|
// ============================================================================
|
|
@@ -226,6 +278,20 @@ if (mdFiles.length === 0) {
|
|
|
226
278
|
}
|
|
227
279
|
parseable++;
|
|
228
280
|
|
|
281
|
+
// ------------------------------------------------------------------
|
|
282
|
+
// Prefix-truncation rule (#1029) — the ` #` case the preamble above names.
|
|
283
|
+
// Runs BEFORE the field rules: a description truncated to a stub still
|
|
284
|
+
// satisfies every one of them, so this is the only place the loss shows up.
|
|
285
|
+
// ------------------------------------------------------------------
|
|
286
|
+
const truncated = detectDescriptionTruncation(content, parsedFm.data, frontmatter);
|
|
287
|
+
if (truncated) {
|
|
288
|
+
fail(
|
|
289
|
+
`${agentName}: description is silently truncated by an unquoted ' #' — YAML parses ` +
|
|
290
|
+
`${truncated.parsedLength} of ${truncated.rawLength} chars and reads the rest as a comment; ` +
|
|
291
|
+
`quote the description (double-quoted, escaping any " inside) so the full text parses`,
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
229
295
|
// ------------------------------------------------------------------
|
|
230
296
|
// Required fields check
|
|
231
297
|
// ------------------------------------------------------------------
|