session-orchestrator 3.20.0 → 3.22.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/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
|
@@ -16,6 +16,29 @@
|
|
|
16
16
|
* IO is fully injectable (existsSync, realpathSync, env, gitRemote) so every
|
|
17
17
|
* branch is unit-testable without touching disk or git.
|
|
18
18
|
*
|
|
19
|
+
* ── Remote resolution (#1039) ────────────────────────────────────────────────
|
|
20
|
+
*
|
|
21
|
+
* The default `gitRemote` reads the repo's remote through the shared core in
|
|
22
|
+
* `vcs-repo-spec.mjs` ({@link resolvePreferredRemote}), VCS-LESS. Two properties
|
|
23
|
+
* of that call are load-bearing here and must not be "tidied":
|
|
24
|
+
*
|
|
25
|
+
* 1. **`vcs` is deliberately omitted.** This module needs a URL, not a
|
|
26
|
+
* platform family, and the vcs-less preference order is `origin` first.
|
|
27
|
+
* That order is what keeps the derived slug STABLE: in a repo carrying both
|
|
28
|
+
* `origin` (→ `<group>/<repo>`) and `gitlab`/`github` mirrors under other
|
|
29
|
+
* namespaces, a `gitlab`-first order would re-namespace — i.e. silently
|
|
30
|
+
* RENAME — every vault note already written under the origin namespace.
|
|
31
|
+
* 2. **Absence and query-failure are kept apart.** The former
|
|
32
|
+
* `git remote get-url origin` implementation returned `''` for *both* "no
|
|
33
|
+
* remote configured" and "git blew up / this is not a repo", and a falsy
|
|
34
|
+
* URL skips the walk-up entirely. The vault then resolved to the
|
|
35
|
+
* single-vault fallback with no trace of why — one `source:'fallback'`
|
|
36
|
+
* label covering a benign repo state and a broken measurement. The
|
|
37
|
+
* fallback result now carries an optional `remoteError` (a
|
|
38
|
+
* `REMOTE_RESOLUTION_REASONS` value) so the two are separable BY VALUE, and
|
|
39
|
+
* only a query failure ({@link isQueryFailure}) WARNs — an absence is
|
|
40
|
+
* normal and stays silent.
|
|
41
|
+
*
|
|
19
42
|
* ── Exports ──────────────────────────────────────────────────────────────────
|
|
20
43
|
*
|
|
21
44
|
* parseNamedVaults(ownerConfig)
|
|
@@ -28,7 +51,7 @@
|
|
|
28
51
|
|
|
29
52
|
import { join, dirname } from 'node:path';
|
|
30
53
|
import { existsSync as nodeExistsSync, realpathSync as nodeRealpathSync } from 'node:fs';
|
|
31
|
-
import {
|
|
54
|
+
import { resolvePreferredRemote, isQueryFailure } from './vcs-repo-spec.mjs';
|
|
32
55
|
|
|
33
56
|
// ---------------------------------------------------------------------------
|
|
34
57
|
// Internal helpers
|
|
@@ -289,12 +312,31 @@ export function findRepoRoot(cwd = process.cwd(), { existsSync = nodeExistsSync,
|
|
|
289
312
|
* `gitRemote(repoRoot)` is called ONLY in the walk-up path — injecting a
|
|
290
313
|
* stub that throws proves the explicit path never calls it.
|
|
291
314
|
*
|
|
315
|
+
* `gitRemote` keeps its `(repoRoot) => string` contract verbatim; the optional
|
|
316
|
+
* second argument is an OUT-parameter the default implementation uses to report
|
|
317
|
+
* WHY it returned `''`. A one-arg stub ignores it and classifies as
|
|
318
|
+
* `'no-remotes'` on `''` / `'git-error'` on a throw — so every pre-existing
|
|
319
|
+
* injection still works unchanged.
|
|
320
|
+
*
|
|
321
|
+
* `gitRun` is injected into the DEFAULT `gitRemote` only, and is ignored when
|
|
322
|
+
* `gitRemote` is supplied (the caller has replaced the thing that would use it).
|
|
323
|
+
* It exists so the default remote-resolution path — preference order, reason
|
|
324
|
+
* propagation — is testable at all; a `gitRemote` stub answers that question
|
|
325
|
+
* before the code under test runs and can only ever confirm itself.
|
|
326
|
+
*
|
|
327
|
+
* `remoteError` is present ONLY on a `source:'fallback'` result that was reached
|
|
328
|
+
* because the remote query produced nothing; it is absent when the fallback was
|
|
329
|
+
* reached for any other reason (no vaults configured, no repo root, the repo IS
|
|
330
|
+
* a vault, no org-prefix match). Additive — pre-#1039 readers see the identical
|
|
331
|
+
* four fields.
|
|
332
|
+
*
|
|
292
333
|
* @param {{
|
|
293
334
|
* vaultName?: string|null,
|
|
294
335
|
* cwd?: string,
|
|
295
336
|
* ownerConfig?: object,
|
|
296
337
|
* env?: Record<string, string|undefined>,
|
|
297
|
-
* gitRemote?: (repoRoot: string) => string,
|
|
338
|
+
* gitRemote?: (repoRoot: string, out?: {reason?: string}) => string,
|
|
339
|
+
* gitRun?: (args: string[]) => {ok: boolean, stdout: string, stderr: string, status?: number, code?: string},
|
|
298
340
|
* existsSync?: Function,
|
|
299
341
|
* realpathSync?: Function,
|
|
300
342
|
* }} [opts]
|
|
@@ -302,7 +344,8 @@ export function findRepoRoot(cwd = process.cwd(), { existsSync = nodeExistsSync,
|
|
|
302
344
|
* root: string|null,
|
|
303
345
|
* suffix: string,
|
|
304
346
|
* name: string|null,
|
|
305
|
-
* source: 'explicit'|'walkup'|'fallback'
|
|
347
|
+
* source: 'explicit'|'walkup'|'fallback',
|
|
348
|
+
* remoteError?: string
|
|
306
349
|
* }}
|
|
307
350
|
*/
|
|
308
351
|
export function resolveNamedVault({
|
|
@@ -310,11 +353,14 @@ export function resolveNamedVault({
|
|
|
310
353
|
cwd = process.cwd(),
|
|
311
354
|
ownerConfig,
|
|
312
355
|
env = process.env,
|
|
313
|
-
gitRemote
|
|
356
|
+
gitRemote,
|
|
357
|
+
gitRun,
|
|
314
358
|
existsSync = nodeExistsSync,
|
|
315
359
|
realpathSync = nodeRealpathSync,
|
|
316
360
|
} = {}) {
|
|
317
361
|
const vaults = parseNamedVaults(ownerConfig);
|
|
362
|
+
const readRemote =
|
|
363
|
+
typeof gitRemote === 'function' ? gitRemote : (root, out) => _defaultGitRemote(root, out, gitRun);
|
|
318
364
|
|
|
319
365
|
// ── Path 1: explicit vault-name ──────────────────────────────────────────
|
|
320
366
|
const trimmedName = typeof vaultName === 'string' ? vaultName.trim() : '';
|
|
@@ -330,18 +376,39 @@ export function resolveNamedVault({
|
|
|
330
376
|
}
|
|
331
377
|
|
|
332
378
|
// ── Path 2: walk-up org-prefix match ────────────────────────────────────
|
|
379
|
+
/** @type {string|undefined} — set ONLY when the remote query is why we fall through */
|
|
380
|
+
let remoteError;
|
|
381
|
+
|
|
333
382
|
if (vaults.length > 0) {
|
|
334
383
|
const repoRoot = findRepoRoot(cwd, { existsSync, realpathSync });
|
|
335
384
|
if (repoRoot !== null) {
|
|
336
|
-
//
|
|
385
|
+
// Resolve the repo's preferred remote (vcs-less → origin-first; see the
|
|
386
|
+
// module docblock for why that order must not move).
|
|
387
|
+
/** @type {{reason?: string, stderr?: string}} */
|
|
388
|
+
const remoteOut = {};
|
|
337
389
|
let remoteUrl;
|
|
338
390
|
try {
|
|
339
|
-
remoteUrl =
|
|
340
|
-
} catch {
|
|
391
|
+
remoteUrl = readRemote(repoRoot, remoteOut);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
// A throwing gitRemote could not answer the question — that is a query
|
|
394
|
+
// failure, never "this repo has no remote".
|
|
341
395
|
remoteUrl = '';
|
|
396
|
+
remoteOut.reason = 'git-error';
|
|
397
|
+
remoteOut.stderr = err instanceof Error ? err.message : String(err);
|
|
342
398
|
}
|
|
343
399
|
|
|
344
|
-
if (remoteUrl) {
|
|
400
|
+
if (!remoteUrl) {
|
|
401
|
+
// Falsy URL: keep the REASON instead of collapsing it into a bare
|
|
402
|
+
// `source:'fallback'`. A one-arg stub that reported nothing means the
|
|
403
|
+
// benign "no remote configured" case.
|
|
404
|
+
remoteError = remoteOut.reason ?? 'no-remotes';
|
|
405
|
+
if (isQueryFailure(remoteError)) {
|
|
406
|
+
const detail = remoteOut.stderr ? `: ${String(remoteOut.stderr).trim()}` : '';
|
|
407
|
+
process.stderr.write(
|
|
408
|
+
`WARN named-vault-resolver: could not read the git remote of "${repoRoot}" (${remoteError})${detail}; falling back to the single-vault default\n`,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
} else {
|
|
345
412
|
// Derive org/repo from the remote URL (strip suffix/.git/scheme)
|
|
346
413
|
const repoSlug = _deriveSlugFromRemote(remoteUrl);
|
|
347
414
|
|
|
@@ -366,11 +433,14 @@ export function resolveNamedVault({
|
|
|
366
433
|
}
|
|
367
434
|
|
|
368
435
|
// ── Path 3: single-vault fallback ────────────────────────────────────────
|
|
436
|
+
// `remoteError` is spread in only when set, so a fallback reached for any
|
|
437
|
+
// other reason keeps the exact pre-#1039 four-field shape.
|
|
369
438
|
return {
|
|
370
439
|
root: null,
|
|
371
440
|
suffix: _resolveEnvSuffix(env) ?? DEFAULT_SUFFIX,
|
|
372
441
|
name: null,
|
|
373
442
|
source: 'fallback',
|
|
443
|
+
...(remoteError === undefined ? {} : { remoteError }),
|
|
374
444
|
};
|
|
375
445
|
}
|
|
376
446
|
|
|
@@ -420,14 +490,33 @@ function _deriveSlugFromRemote(url) {
|
|
|
420
490
|
}
|
|
421
491
|
|
|
422
492
|
/**
|
|
423
|
-
* Default gitRemote implementation
|
|
493
|
+
* Default gitRemote implementation (#1039).
|
|
494
|
+
*
|
|
495
|
+
* Delegates to the shared {@link resolvePreferredRemote} core VCS-LESS, which
|
|
496
|
+
* makes this resolver work in the repos the old hard-coded
|
|
497
|
+
* `git remote get-url origin` was blind in — a repo whose remotes are named
|
|
498
|
+
* `gitlab`/`github`, a fork whose sole remote is `upstream`. The vcs-less
|
|
499
|
+
* preference order still tries `origin` FIRST, so the slug derived for a repo
|
|
500
|
+
* that has an `origin` is byte-identical to the pre-#1039 value.
|
|
501
|
+
*
|
|
502
|
+
* Return type stays `string` (the `gitRemote` DI contract depends on it). The
|
|
503
|
+
* failure reason travels through the optional `out` OUT-parameter instead:
|
|
504
|
+
* folding it into the return value would have meant changing that contract for
|
|
505
|
+
* every injected stub.
|
|
506
|
+
*
|
|
424
507
|
* @param {string} repoRoot
|
|
425
|
-
* @
|
|
508
|
+
* @param {{reason?: string, stderr?: string}} [out] — populated with the
|
|
509
|
+
* {@link REMOTE_RESOLUTION_REASONS} reason when the resolution failed
|
|
510
|
+
* @param {Function} [gitRun] — injectable git runner; `undefined` uses the real one
|
|
511
|
+
* @returns {string} the remote URL, or `''` when none resolved
|
|
426
512
|
*/
|
|
427
|
-
function _defaultGitRemote(repoRoot) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (
|
|
432
|
-
|
|
513
|
+
function _defaultGitRemote(repoRoot, out, gitRun) {
|
|
514
|
+
// `vcs` deliberately omitted — see the module docblock (origin-first order).
|
|
515
|
+
const resolved = resolvePreferredRemote({ repoRoot, gitRun });
|
|
516
|
+
if (resolved.ok) return resolved.url;
|
|
517
|
+
if (isPlainObject(out)) {
|
|
518
|
+
out.reason = resolved.reason;
|
|
519
|
+
if (resolved.stderr) out.stderr = resolved.stderr;
|
|
520
|
+
}
|
|
521
|
+
return '';
|
|
433
522
|
}
|
|
@@ -13,10 +13,16 @@
|
|
|
13
13
|
* to AskUserQuestion in the coordinator. Each object has the shape:
|
|
14
14
|
* { question, header, options: [{ label, description }], multiSelect }
|
|
15
15
|
*
|
|
16
|
+
* optionValue(label)
|
|
17
|
+
* Maps a displayed option label to the value stored in owner.yaml by
|
|
18
|
+
* stripping the trailing `(Recommended)` marker. The label and the stored
|
|
19
|
+
* value are deliberately NOT the same string — see the function comment.
|
|
20
|
+
*
|
|
16
21
|
* applyInterviewAnswers(answers, { path? } = {})
|
|
17
22
|
* Accepts an array of selected option labels (one per question, same order
|
|
18
|
-
* as getInterviewQuestions()),
|
|
19
|
-
* and writes owner.yaml via
|
|
23
|
+
* as getInterviewQuestions()), resolves each through optionValue(), validates
|
|
24
|
+
* the result against validateOwnerConfig, and writes owner.yaml via
|
|
25
|
+
* writeOwnerConfig.
|
|
20
26
|
* Returns { ok, path, errors }.
|
|
21
27
|
*
|
|
22
28
|
* runOwnerInterview({ skipIfExists?, force?, path? } = {})
|
|
@@ -37,6 +43,40 @@ import {
|
|
|
37
43
|
getDefaults,
|
|
38
44
|
} from './owner-yaml.mjs';
|
|
39
45
|
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Label ←→ stored value
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A trailing recommendation marker on an option LABEL — the display half of a
|
|
52
|
+
* label, never part of the stored value.
|
|
53
|
+
*
|
|
54
|
+
* Anchored at the end and non-greedy about whitespace so it can only ever strip
|
|
55
|
+
* a suffix; a value that merely CONTAINS the word (there is none today) survives.
|
|
56
|
+
*/
|
|
57
|
+
const RECOMMENDED_SUFFIX = /\s*\((?:Recommended|Empfohlen|Default)\)\s*$/u;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Map an AUQ option label back to the value that is written to owner.yaml.
|
|
61
|
+
*
|
|
62
|
+
* The label and the stored value used to be the SAME string, which made the
|
|
63
|
+
* label unchangeable: `applyInterviewAnswers()` matches the answer against
|
|
64
|
+
* closed enums (`['direct','neutral','friendly']` and friends) and falls back to
|
|
65
|
+
* a default on any miss. Adding the `(Recommended)` marker AUQ-003 requires
|
|
66
|
+
* would therefore have turned a picked `direct` into a silently-stored
|
|
67
|
+
* `neutral` — a wrong answer written to disk with no error anywhere.
|
|
68
|
+
*
|
|
69
|
+
* Splitting the two keeps the label free for the operator and the value pinned
|
|
70
|
+
* to the enum. Everything before the marker is the value, verbatim.
|
|
71
|
+
*
|
|
72
|
+
* @param {unknown} label — the option label as selected by the operator
|
|
73
|
+
* @returns {string} the enum value to store, or '' for a non-string input
|
|
74
|
+
*/
|
|
75
|
+
export function optionValue(label) {
|
|
76
|
+
if (typeof label !== 'string') return '';
|
|
77
|
+
return label.replace(RECOMMENDED_SUFFIX, '').trim();
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
// ---------------------------------------------------------------------------
|
|
41
81
|
// Question definitions
|
|
42
82
|
// ---------------------------------------------------------------------------
|
|
@@ -50,51 +90,51 @@ import {
|
|
|
50
90
|
export function getInterviewQuestions() {
|
|
51
91
|
return [
|
|
52
92
|
{
|
|
53
|
-
question: 'Which language should the assistant
|
|
54
|
-
header: '
|
|
93
|
+
question: 'Which language should the assistant answer in?',
|
|
94
|
+
header: 'Language 1/5',
|
|
55
95
|
options: [
|
|
56
|
-
{ label: 'de', description: 'German —
|
|
57
|
-
{ label: 'en', description: 'English —
|
|
58
|
-
{ label: 'other', description: '
|
|
96
|
+
{ label: 'de', description: 'German — answers, narration and questions all in Deutsch.' },
|
|
97
|
+
{ label: 'en', description: 'English — answers, narration and questions all in English.' },
|
|
98
|
+
{ label: 'other', description: 'Stored as English either way — only de and en are accepted. Change it later in owner.yaml (your settings file).' },
|
|
59
99
|
],
|
|
60
100
|
multiSelect: false,
|
|
61
101
|
},
|
|
62
102
|
{
|
|
63
|
-
question: '
|
|
64
|
-
header: '
|
|
103
|
+
question: 'How should the assistant talk to you?',
|
|
104
|
+
header: 'Tone 2/5',
|
|
65
105
|
options: [
|
|
66
|
-
{ label: 'direct', description: '
|
|
67
|
-
{ label: 'neutral', description: '
|
|
68
|
-
{ label: 'friendly', description: 'Warm and conversational
|
|
106
|
+
{ label: 'direct (Recommended)', description: 'No filler, no praise, straight to the point — fastest to read once you know the project.' },
|
|
107
|
+
{ label: 'neutral', description: 'Professional without being terse. Pick this if direct reads too blunt.' },
|
|
108
|
+
{ label: 'friendly', description: 'Warm and conversational. Costs a few lines per answer, and suits open-ended exploration.' },
|
|
69
109
|
],
|
|
70
110
|
multiSelect: false,
|
|
71
111
|
},
|
|
72
112
|
{
|
|
73
|
-
question: 'How much
|
|
74
|
-
header: '
|
|
113
|
+
question: 'How much should the assistant write by default?',
|
|
114
|
+
header: 'Output 3/5',
|
|
75
115
|
options: [
|
|
76
|
-
{ label: '
|
|
77
|
-
{ label: '
|
|
78
|
-
{ label: 'ultra', description: '
|
|
116
|
+
{ label: 'full (Recommended)', description: 'Terse but complete: narration trimmed, every fact kept. Safe default — you lose words, never data.' },
|
|
117
|
+
{ label: 'lite', description: 'Keeps the explanations and background too. Slower to read, better while the codebase is still new to you.' },
|
|
118
|
+
{ label: 'ultra', description: 'Code and decisions only, no narration. You will have to ask why more often.' },
|
|
79
119
|
],
|
|
80
120
|
multiSelect: false,
|
|
81
121
|
},
|
|
82
122
|
{
|
|
83
|
-
question: 'How should the assistant
|
|
84
|
-
header: '
|
|
123
|
+
question: 'How much should the assistant explain before it starts working?',
|
|
124
|
+
header: 'Preamble 4/5',
|
|
85
125
|
options: [
|
|
86
|
-
{ label: 'minimal', description: 'One
|
|
87
|
-
{ label: 'verbose', description: '
|
|
126
|
+
{ label: 'minimal (Recommended)', description: 'One line of status, then it works. Safe default — you can still ask for the reasoning afterwards.' },
|
|
127
|
+
{ label: 'verbose', description: 'Plan and reasoning before each major action. Costs a few lines every step.' },
|
|
88
128
|
],
|
|
89
129
|
multiSelect: false,
|
|
90
130
|
},
|
|
91
131
|
{
|
|
92
|
-
question: '
|
|
93
|
-
header: '
|
|
132
|
+
question: 'May the plugin share anonymized hardware data to improve its resource defaults?',
|
|
133
|
+
header: 'Sharing 5/5',
|
|
94
134
|
options: [
|
|
95
|
-
{ label: 'No', description: '
|
|
96
|
-
{ label: 'Yes', description: '
|
|
97
|
-
{ label: 'Preview', description: '
|
|
135
|
+
{ label: 'No (Recommended)', description: 'Nothing leaves this machine. Safe default — you can switch it on later without redoing this interview.' },
|
|
136
|
+
{ label: 'Yes', description: 'Shares hashed hardware patterns — never file names, paths or content. Helps tune the wave and session defaults.' },
|
|
137
|
+
{ label: 'Preview', description: 'Shows exactly what would be sent, then asks again. Costs one extra step.' },
|
|
98
138
|
],
|
|
99
139
|
multiSelect: false,
|
|
100
140
|
},
|
|
@@ -108,12 +148,15 @@ export function getInterviewQuestions() {
|
|
|
108
148
|
/**
|
|
109
149
|
* Map interview answer labels to an owner.yaml config object and write it.
|
|
110
150
|
*
|
|
151
|
+
* Each answer is the LABEL the operator picked, which may carry a trailing
|
|
152
|
+
* `(Recommended)` marker; optionValue() strips it before the enum match below.
|
|
153
|
+
*
|
|
111
154
|
* @param {string[]} answers - Array of selected option labels, one per question (5 total).
|
|
112
|
-
* answers[0] = language label ('de' | 'en' |
|
|
113
|
-
* answers[1] = tone style label
|
|
114
|
-
* answers[2] = output level label
|
|
115
|
-
* answers[3] = preamble label
|
|
116
|
-
* answers[4] = hardware-sharing label ('Yes' | 'No' | 'Preview')
|
|
155
|
+
* answers[0] = language label ('de' | 'en' | 'other')
|
|
156
|
+
* answers[1] = tone style label ('direct' | 'neutral' | 'friendly', ± marker)
|
|
157
|
+
* answers[2] = output level label ('full' | 'lite' | 'ultra', ± marker)
|
|
158
|
+
* answers[3] = preamble label ('minimal' | 'verbose', ± marker)
|
|
159
|
+
* answers[4] = hardware-sharing label ('Yes' | 'No' | 'Preview', ± marker)
|
|
117
160
|
* @param {{ path?: string }} [opts]
|
|
118
161
|
* @returns {{ ok: boolean, path: string, errors: string[] }}
|
|
119
162
|
*/
|
|
@@ -124,7 +167,10 @@ export function applyInterviewAnswers(answers, opts = {}) {
|
|
|
124
167
|
return { ok: false, path: filePath, errors: ['applyInterviewAnswers requires exactly 5 answers'] };
|
|
125
168
|
}
|
|
126
169
|
|
|
127
|
-
|
|
170
|
+
// Strip the display-only `(Recommended)` marker before matching against the
|
|
171
|
+
// enums below — see optionValue(). A non-string answer becomes '' and falls
|
|
172
|
+
// through to the same default it always did.
|
|
173
|
+
const [langRaw, toneRaw, outputLevelRaw, preambleRaw, hwConsentRaw] = answers.map(optionValue);
|
|
128
174
|
|
|
129
175
|
// --- Language ---
|
|
130
176
|
// Accept 'de', 'en', or treat anything else as a free-text language code.
|
|
@@ -6,8 +6,12 @@
|
|
|
6
6
|
* YAML frontmatter on read and before write.
|
|
7
7
|
*
|
|
8
8
|
* Design notes:
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
9
|
+
* - Pure regex/checks rather than `skills/vault-sync/validator.mjs` (zod).
|
|
10
|
+
* (This line used to cite `scripts/lib/mission-status-schema.mjs` as the
|
|
11
|
+
* exemplar for that convention; that module was deleted on 2026-08-15 for
|
|
12
|
+
* having zero production callers while three prose sites promised it ran.
|
|
13
|
+
* The convention below stands on its own reason, stated next.)
|
|
14
|
+
* The repo root has
|
|
11
15
|
* no `zod` dependency — only `skills/vault-sync/` does, scoped to that
|
|
12
16
|
* skill's own `node_modules`. Keeping this validator dependency-free lets
|
|
13
17
|
* callers from anywhere under `scripts/lib/` use it without extra setup.
|
|
@@ -67,6 +67,7 @@ import { discoverActiveSessions } from './session-discovery.mjs';
|
|
|
67
67
|
import { readLock, isLockLive, LOCK_PATH } from './session-lock.mjs';
|
|
68
68
|
import { checkPeerStateMd } from './state-md-peer-guard.mjs';
|
|
69
69
|
import { listWorktreesChecked } from './worktree/listing.mjs';
|
|
70
|
+
import { parseSessionId } from './session-id.mjs';
|
|
70
71
|
|
|
71
72
|
/** Closed enum of provenance sources. */
|
|
72
73
|
const SOURCE_DISCOVERED = 'discovered'; // lock + registry unified (irreversibly merged upstream)
|
|
@@ -120,24 +121,64 @@ function _peerFromDiscovered(s, nowMs) {
|
|
|
120
121
|
return peer;
|
|
121
122
|
}
|
|
122
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Resolve a semantic hint to the concrete local raw ID for discovered surfaces.
|
|
126
|
+
*
|
|
127
|
+
* Semantic labels are attribution, not ownership. A mapping is usable only when
|
|
128
|
+
* current-session.json names the hint and its raw id exactly matches the local
|
|
129
|
+
* readable session.lock. Otherwise return null so a potentially foreign record
|
|
130
|
+
* stays visible. Raw caller ids retain the legacy direct comparison unchanged.
|
|
131
|
+
*
|
|
132
|
+
* This is a consistency check on two repo-global files, NOT a proof of ownership;
|
|
133
|
+
* a colliding semantic label from a foreign live session satisfies it. See the
|
|
134
|
+
* call site in findPeers for the measured case and why it is not closed here.
|
|
135
|
+
*
|
|
136
|
+
* @param {string|null} mySessionId
|
|
137
|
+
* @param {string} repoRoot
|
|
138
|
+
* @returns {string|null}
|
|
139
|
+
*/
|
|
140
|
+
function _discoveredSelfSessionId(mySessionId, repoRoot) {
|
|
141
|
+
if (typeof mySessionId !== 'string' || mySessionId.length === 0) return null;
|
|
142
|
+
if (parseSessionId(mySessionId)?.format !== 'semantic') return mySessionId;
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const raw = fs.readFileSync(
|
|
146
|
+
path.join(repoRoot, '.orchestrator', 'current-session.json'),
|
|
147
|
+
'utf8',
|
|
148
|
+
);
|
|
149
|
+
const current = JSON.parse(raw);
|
|
150
|
+
const lock = readLock({ repoRoot });
|
|
151
|
+
if (
|
|
152
|
+
current &&
|
|
153
|
+
typeof current === 'object' &&
|
|
154
|
+
!Array.isArray(current) &&
|
|
155
|
+
typeof current.session_id === 'string' &&
|
|
156
|
+
current.session_id.length > 0 &&
|
|
157
|
+
current.semantic_session_id === mySessionId &&
|
|
158
|
+
lock !== null &&
|
|
159
|
+
typeof lock.session_id === 'string' &&
|
|
160
|
+
lock.session_id === current.session_id
|
|
161
|
+
) {
|
|
162
|
+
return lock.session_id;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Missing or malformed local state cannot prove a semantic alias.
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
123
170
|
/**
|
|
124
171
|
* findPeers — union of all 3 peer-discovery surfaces, fail-open per surface.
|
|
125
172
|
*
|
|
126
173
|
* @param {string} repoRoot Absolute path to the repository root.
|
|
127
174
|
* @param {object} [opts] passthrough seams shared with the underlying surfaces.
|
|
128
|
-
* @param {string|null} [opts.mySessionId] Current session id
|
|
129
|
-
* self-
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* the SAME id-space `mySessionId` was derived from (see the PRECONDITION
|
|
136
|
-
* note in state-md-peer-guard.mjs). Passing a semantic id here self-excludes
|
|
137
|
-
* correctly on Surface C but NOT on Surface A+B (the UUID lock/registry
|
|
138
|
-
* entry for the same session will still surface as a 'discovered' peer of
|
|
139
|
-
* itself) — callers that need both surfaces to self-exclude MUST pass the
|
|
140
|
-
* UUID.
|
|
175
|
+
* @param {string|null} [opts.mySessionId] Current session id hint. Raw
|
|
176
|
+
* ids self-exclude directly on Surface A+B. A semantic hint maps to a raw
|
|
177
|
+
* discovered-surface id only after the local current-session.json semantic
|
|
178
|
+
* label matches it AND that file's raw id exactly equals local
|
|
179
|
+
* session.lock.session_id. Missing, malformed, or mismatched local state
|
|
180
|
+
* maps nothing, leaving discovered entries visible. Surface C receives this
|
|
181
|
+
* original hint unchanged and compares it to STATE.md's `session:` field.
|
|
141
182
|
* @param {number} [opts.now] ms-since-epoch (test seam for freshness/age).
|
|
142
183
|
* @param {number} [opts.freshnessMin] Registry-entry freshness threshold (minutes).
|
|
143
184
|
* @param {number} [opts.maxAgeHours] STATE.md abandonment threshold (hours).
|
|
@@ -155,6 +196,7 @@ function _peerFromDiscovered(s, nowMs) {
|
|
|
155
196
|
export async function findPeers(repoRoot, opts = {}) {
|
|
156
197
|
const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
|
|
157
198
|
const mySessionId = opts.mySessionId ?? null;
|
|
199
|
+
const discoveredSelfSessionId = _discoveredSelfSessionId(mySessionId, repoRoot);
|
|
158
200
|
|
|
159
201
|
const peers = [];
|
|
160
202
|
|
|
@@ -172,15 +214,24 @@ export async function findPeers(repoRoot, opts = {}) {
|
|
|
172
214
|
});
|
|
173
215
|
if (Array.isArray(discovered)) {
|
|
174
216
|
for (const s of discovered) {
|
|
175
|
-
// Self-exclusion (#798):
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
217
|
+
// Self-exclusion (#798, #1085): discovered entries carry raw ids. A
|
|
218
|
+
// semantic caller hint filters one such id only after the local raw
|
|
219
|
+
// binding was verified above.
|
|
220
|
+
//
|
|
221
|
+
// The binding proves CONSISTENCY of the two local files, not OWNERSHIP.
|
|
222
|
+
// Both are repo-global, so if a foreign live session wrote them last and
|
|
223
|
+
// its semantic label collides with this hint (labels like
|
|
224
|
+
// `main-2026-08-21-session-2` are routinely shared — see
|
|
225
|
+
// hooks/on-session-start.mjs), that foreign raw id is filtered and the
|
|
226
|
+
// peer PSA-002 exists to surface goes missing. Measured 2026-08-21:
|
|
227
|
+
// null hint -> peer visible, colliding semantic hint -> peers: [].
|
|
228
|
+
// Closing it needs a per-process ownership proof, not a stronger read
|
|
229
|
+
// of the same two files.
|
|
230
|
+
if (
|
|
231
|
+
s &&
|
|
232
|
+
typeof s.sessionId === 'string' &&
|
|
233
|
+
s.sessionId !== discoveredSelfSessionId
|
|
234
|
+
) {
|
|
184
235
|
peers.push(_peerFromDiscovered(s, nowMs));
|
|
185
236
|
}
|
|
186
237
|
}
|
|
@@ -212,6 +212,21 @@ function duBytes(absPath) {
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Human-readable byte size. Sub-megabyte totals are the common case once the
|
|
217
|
+
* aged subset is sized correctly, and `Math.round(bytes / MB)` renders every
|
|
218
|
+
* one of them as "0 MB" — which is why the unit is chosen, not fixed.
|
|
219
|
+
* @param {number} bytes
|
|
220
|
+
* @returns {string}
|
|
221
|
+
*/
|
|
222
|
+
function formatBytes(bytes) {
|
|
223
|
+
const mb = bytes / (1024 * 1024);
|
|
224
|
+
if (mb >= 10) return `${Math.round(mb)} MB`;
|
|
225
|
+
if (mb >= 1) return `${mb.toFixed(2)} MB`;
|
|
226
|
+
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
227
|
+
return `${bytes} B`;
|
|
228
|
+
}
|
|
229
|
+
|
|
215
230
|
/**
|
|
216
231
|
* H3 — Aged orchestrator artifacts.
|
|
217
232
|
*
|
|
@@ -219,17 +234,48 @@ function duBytes(absPath) {
|
|
|
219
234
|
* 147 MB were Playwright test-run captures dating back seven weeks, plus
|
|
220
235
|
* 592 files older than 30 days.
|
|
221
236
|
*
|
|
237
|
+
* Counts and sizes ONLY untracked aged files. Two properties are load-bearing
|
|
238
|
+
* and were both defects until 2026-08-22:
|
|
239
|
+
*
|
|
240
|
+
* 1. The byte total describes the set the sentence names — the aged files —
|
|
241
|
+
* not the whole directory. Sizing the directory overstated the reachable
|
|
242
|
+
* win by a factor of ~18 in this repo (11 MB claimed, 0.68 MB real).
|
|
243
|
+
* 2. Version-tracked paths are excluded. `.orchestrator/policy/*.json` and
|
|
244
|
+
* `.orchestrator/steering/*.md` are read at runtime by hooks and skills;
|
|
245
|
+
* their age is a sign of stability, not decay. Proposing them for
|
|
246
|
+
* "pruning" is proposing to delete source.
|
|
247
|
+
*
|
|
222
248
|
* @param {string} repoRoot
|
|
223
249
|
* @param {number} ageDays
|
|
224
250
|
* @param {number} now
|
|
225
|
-
* @returns {
|
|
251
|
+
* @returns {{check: string, fixable: boolean, agedFiles: number, agedBytes: number, message: string}|null}
|
|
226
252
|
*/
|
|
227
253
|
export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAYS, now = Date.now()) {
|
|
228
254
|
const dir = join(repoRoot, '.orchestrator');
|
|
229
255
|
if (!existsSync(dir)) return null;
|
|
230
256
|
|
|
257
|
+
// Resolve the tracked set ONCE per call — `git ls-files --error-unmatch` per
|
|
258
|
+
// file would cost one process per candidate for the same answer.
|
|
259
|
+
//
|
|
260
|
+
// FAIL-SAFE, NOT FAIL-OPEN: when git cannot answer (no repo, git absent,
|
|
261
|
+
// non-zero exit) source and artifact are indistinguishable, so the probe
|
|
262
|
+
// stays SILENT rather than falling back to "nothing is tracked" — that
|
|
263
|
+
// fallback IS the defect this exclusion closes, and it would return
|
|
264
|
+
// invisibly. Losing an advisory finding costs nothing; proposing to delete
|
|
265
|
+
// versioned files costs a restore. `-z` suppresses git's path quoting, so
|
|
266
|
+
// non-ASCII and space-bearing paths compare byte-exactly.
|
|
267
|
+
const trackedRaw = git(['ls-files', '-z', '--', '.orchestrator'], repoRoot);
|
|
268
|
+
if (trackedRaw === null) return null;
|
|
269
|
+
const tracked = new Set(
|
|
270
|
+
trackedRaw
|
|
271
|
+
.split('\0')
|
|
272
|
+
.filter(Boolean)
|
|
273
|
+
.map((p) => join(repoRoot, p)),
|
|
274
|
+
);
|
|
275
|
+
|
|
231
276
|
const cutoff = now - ageDays * 24 * 60 * 60 * 1000;
|
|
232
277
|
let aged = 0;
|
|
278
|
+
let agedBytes = 0;
|
|
233
279
|
let scanned = 0;
|
|
234
280
|
|
|
235
281
|
/** @param {string} d @param {number} depth */
|
|
@@ -248,9 +294,16 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
248
294
|
if (e.isDirectory()) {
|
|
249
295
|
walk(full, depth + 1);
|
|
250
296
|
} else if (e.isFile()) {
|
|
297
|
+
// Counted before the tracked-skip so the 20k bound still measures the
|
|
298
|
+
// walk, not the reportable subset.
|
|
251
299
|
scanned++;
|
|
300
|
+
if (tracked.has(full)) continue;
|
|
252
301
|
try {
|
|
253
|
-
|
|
302
|
+
const st = statSync(full);
|
|
303
|
+
if (st.mtimeMs < cutoff) {
|
|
304
|
+
aged++;
|
|
305
|
+
agedBytes += st.size;
|
|
306
|
+
}
|
|
254
307
|
} catch {
|
|
255
308
|
/* vanished mid-scan — ignore */
|
|
256
309
|
}
|
|
@@ -261,11 +314,18 @@ export function checkStaleArtifacts(repoRoot, ageDays = DEFAULT_ARTIFACT_AGE_DAY
|
|
|
261
314
|
|
|
262
315
|
if (aged === 0) return null;
|
|
263
316
|
|
|
264
|
-
|
|
317
|
+
// `fixable: true` stays. The only consumer is skills/session-start/SKILL.md
|
|
318
|
+
// (Phase 4), which routes fixable findings to "safe batch work" instead of
|
|
319
|
+
// the operator Q&A — prose guidance to the coordinator, not an automatic
|
|
320
|
+
// deletion run; `grep -rn "fixable" scripts/ skills/ hooks/` finds no other
|
|
321
|
+
// reader than the `mechanical` count below. With tracked paths excluded the
|
|
322
|
+
// claim is now true: every reported path is an untracked artifact.
|
|
265
323
|
return {
|
|
266
324
|
check: 'stale-artifacts',
|
|
267
325
|
fixable: true,
|
|
268
|
-
|
|
326
|
+
agedFiles: aged,
|
|
327
|
+
agedBytes,
|
|
328
|
+
message: `${aged} untracked file(s) under .orchestrator/ older than ${ageDays}d${agedBytes > 0 ? ` (${formatBytes(agedBytes)} total)` : ''} — candidates for pruning`,
|
|
269
329
|
};
|
|
270
330
|
}
|
|
271
331
|
|