session-orchestrator 3.20.0 → 3.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/CHANGELOG.md +414 -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 +1 -1
- 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/release.md +60 -0
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +11 -1
- package/hooks/on-session-end.mjs +52 -5
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +1 -1
- package/pi/prompts/release.md +12 -0
- package/scripts/autopilot.mjs +3 -1
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- 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 +207 -23
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +193 -7
- 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/peer-cards/schema.mjs +6 -2
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- 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/state-md/mission-status.mjs +21 -12
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- 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 +18 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- 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-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 +500 -19
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +616 -61
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +61 -0
- package/scripts/validate-wave-scope.mjs +281 -12
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/state-ownership.md +17 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- 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/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +49 -39
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/memory-cleanup/SKILL.md +18 -5
- package/skills/npm-publish/SKILL.md +22 -50
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/mode-new.md +23 -5
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +44 -2
- package/skills/session-end/metrics-collection.md +1 -1
- 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 +13 -1
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/tmux-layout/SKILL.md +7 -2
- 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 +42 -12
- package/scripts/lib/mission-status-schema.mjs +0 -114
package/scripts/lib/io.mjs
CHANGED
|
@@ -17,7 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
mkdirSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
writeSync,
|
|
27
|
+
copyFileSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
} from 'node:fs';
|
|
21
30
|
import path, { dirname } from 'node:path';
|
|
22
31
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
23
32
|
|
|
@@ -556,6 +565,26 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
|
|
|
556
565
|
* used from any layer without violating the layering rule in
|
|
557
566
|
* `scripts/lib/hardening.mjs`.
|
|
558
567
|
*
|
|
568
|
+
* Implementation: this is the JSON-serializing façade over
|
|
569
|
+
* {@link atomicWriteWithBackup} (`backup: false`) — the two functions carried
|
|
570
|
+
* the identical `mkdir → tmp → write → rename → catch` body 60 lines apart,
|
|
571
|
+
* which is the very duplication `atomicWriteWithBackup` was introduced to end.
|
|
572
|
+
* Everything below the `JSON.stringify` is the delegate's; the wrapper exists
|
|
573
|
+
* for the serialization step and for NARROWING the envelope (see @returns).
|
|
574
|
+
*
|
|
575
|
+
* Two things are deliberately kept here rather than pushed into the delegate:
|
|
576
|
+
*
|
|
577
|
+
* - **The stringify stays inside a try.** `JSON.stringify` throws on a cycle
|
|
578
|
+
* (and on a BigInt). Hoisting it above the delegation call would turn that
|
|
579
|
+
* into an escaping TypeError, and 4 of the 9 caller files (`loop-guard`,
|
|
580
|
+
* `lock-bootstrap`, `file-lock`, `issue-budget`) invoke this without a
|
|
581
|
+
* try/catch of their own. A serialization failure reports as `fs-error`
|
|
582
|
+
* because that is what this helper has always returned for it.
|
|
583
|
+
* - **The success envelope is narrowed back to `{ ok: true }`.** The delegate
|
|
584
|
+
* also reports `path`/`bytes`/`backupPath`; `session-lock.mjs#writeOwnerProof`
|
|
585
|
+
* propagates THIS object verbatim on failure (`if (!w.ok) return w`), so the
|
|
586
|
+
* key set is part of a contract that reaches further than this file.
|
|
587
|
+
*
|
|
559
588
|
* @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
|
|
560
589
|
* @param {*} data JSON-serializable value.
|
|
561
590
|
* @param {object} [opts]
|
|
@@ -565,15 +594,172 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
|
|
|
565
594
|
*/
|
|
566
595
|
export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
567
596
|
const { indent = 2, tmpPrefix = '.tmp' } = opts;
|
|
597
|
+
|
|
598
|
+
let body;
|
|
599
|
+
try {
|
|
600
|
+
body = JSON.stringify(data, null, indent) + '\n';
|
|
601
|
+
} catch (err) {
|
|
602
|
+
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
|
|
606
|
+
return res.ok ? { ok: true } : res;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Content-agnostic sibling of {@link writeJsonAtomicSync}: atomically replace a
|
|
611
|
+
* file with an arbitrary string/Buffer body, optionally snapshotting the
|
|
612
|
+
* previous contents to a timestamped `.bak-<ISO>` sidecar first (issue #734).
|
|
613
|
+
*
|
|
614
|
+
* The repo carries the copy→tmp→rename idiom in ~25 hand-rolled places
|
|
615
|
+
* (`learnings/io.mjs#rewriteLearnings`, `session-record-repair.mjs`,
|
|
616
|
+
* `owner-interview.mjs`, `backfill-learnings.mjs`, …). Each spelling differs in
|
|
617
|
+
* small ways — some take the backup, some do not; some `mkdir -p`, some assume
|
|
618
|
+
* the directory exists — which is exactly how a crash-safety guarantee rots.
|
|
619
|
+
* This helper is the shared spelling; **validation policy stays with the
|
|
620
|
+
* callers** (this function never inspects `body`).
|
|
621
|
+
*
|
|
622
|
+
* Crash-safety: write `<dir>/<tmpPrefix>.<rand>`, then `renameSync` over the
|
|
623
|
+
* target. Same-filesystem rename is atomic on POSIX, so an observer sees either
|
|
624
|
+
* the previous contents or the new ones — never a half-written file. The tmp
|
|
625
|
+
* file is created as a SIBLING of the target on purpose: a tmp in `os.tmpdir()`
|
|
626
|
+
* may sit on a different filesystem, where `rename` degrades to a non-atomic
|
|
627
|
+
* copy (EXDEV).
|
|
628
|
+
*
|
|
629
|
+
* Backup (`backup: true`) copies the CURRENT file to `<filePath>.bak-<ISO>`
|
|
630
|
+
* before the rename, with `:`/`.` swapped for `-` so a lexical sort of the
|
|
631
|
+
* siblings is chronological (same convention as `learnings/io.mjs`). A
|
|
632
|
+
* first-time write has nothing to lose, so no backup is taken when the target
|
|
633
|
+
* does not exist. **Rotation is deliberately NOT done here** — how many
|
|
634
|
+
* snapshots a store is worth is a per-caller policy (`learnings/io.mjs` keeps
|
|
635
|
+
* 3; a re-derivable file wants 0), and a keep-N default baked into the
|
|
636
|
+
* primitive would silently unlink a caller's snapshots.
|
|
637
|
+
*
|
|
638
|
+
* Caller is responsible for path-confinement — this helper does NOT validate
|
|
639
|
+
* that `filePath` lives inside the project (mirrors {@link writeJsonAtomic} /
|
|
640
|
+
* {@link writeJsonAtomicSync}).
|
|
641
|
+
*
|
|
642
|
+
* On a FAILED write the tmp sibling is unlinked (best-effort) before the error
|
|
643
|
+
* envelope is returned. Without that, every failed write leaves a
|
|
644
|
+
* `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
|
|
645
|
+
* so the litter accumulates in the operator's vault directory. The cleanup is
|
|
646
|
+
* attempted only when the write actually created the tmp file, and its own
|
|
647
|
+
* failure is swallowed: a leaked tmp is worse than a silent unlink miss, and
|
|
648
|
+
* neither may mask the original error.
|
|
649
|
+
*
|
|
650
|
+
* ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
|
|
651
|
+
* TWO PRODUCTION CALL-SITES: `vault-status/board-writer.mjs#writeBoard`
|
|
652
|
+
* (`backup: false`) and {@link writeJsonAtomicSync}, which carries 12 further
|
|
653
|
+
* call-sites across 9 files behind it. That second one is the load-bearing
|
|
654
|
+
* evidence — the previous revision of this note recorded ONE call-site and
|
|
655
|
+
* concluded the signature was unproven, while the function with an identical
|
|
656
|
+
* body sat 60 lines above in this same file, unmigrated. The cheapest possible
|
|
657
|
+
* migration going unmade is not a neutral fact about a helper: it is the
|
|
658
|
+
* measurement that the helper is not paying for itself.
|
|
659
|
+
*
|
|
660
|
+
* What the second call-site does NOT prove: `writeJsonAtomicSync` passes
|
|
661
|
+
* `backup: false` and no `fs`, so the backup half and the injection seam still
|
|
662
|
+
* rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
|
|
663
|
+
* migrates the remaining hand-rolled sites, re-check before widening:
|
|
664
|
+
* (a) whether an `async` twin is needed rather than bolting a promise mode onto
|
|
665
|
+
* this one (three known sites are `fs/promises`), and (b) whether rotation
|
|
666
|
+
* belongs here after all (it does only if ≥2 migrated callers want the SAME
|
|
667
|
+
* keep-N). If NO caller ever passes `backup: true` in production, that half is
|
|
668
|
+
* still the part to shrink back.
|
|
669
|
+
*
|
|
670
|
+
* @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
|
|
671
|
+
* @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
|
|
672
|
+
* @param {object} [opts]
|
|
673
|
+
* @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
|
|
674
|
+
* @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
|
|
675
|
+
* @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
|
|
676
|
+
* @param {Date} [opts.now] Clock seam for the backup stamp (tests).
|
|
677
|
+
* @param {{ mkdirSync?: Function, writeFileSync?: Function, renameSync?: Function,
|
|
678
|
+
* copyFileSync?: Function, existsSync?: Function, unlinkSync?: Function }} [opts.fs]
|
|
679
|
+
* Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
|
|
680
|
+
* the backup path, which fails closed (see below).
|
|
681
|
+
* @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
|
|
682
|
+
* | { ok: false, reason: 'fs-error', error: string }}
|
|
683
|
+
*/
|
|
684
|
+
export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
685
|
+
const {
|
|
686
|
+
encoding = 'utf8',
|
|
687
|
+
backup = false,
|
|
688
|
+
tmpPrefix = '.tmp',
|
|
689
|
+
now = new Date(),
|
|
690
|
+
fs: injectedFs,
|
|
691
|
+
} = opts;
|
|
692
|
+
|
|
693
|
+
const fsMkdir = injectedFs?.mkdirSync ?? mkdirSync;
|
|
694
|
+
const fsWriteFile = injectedFs?.writeFileSync ?? writeFileSync;
|
|
695
|
+
const fsRename = injectedFs?.renameSync ?? renameSync;
|
|
696
|
+
const fsCopyFile = injectedFs?.copyFileSync ?? copyFileSync;
|
|
697
|
+
const fsExists = injectedFs?.existsSync ?? existsSync;
|
|
698
|
+
const fsUnlink = injectedFs?.unlinkSync ?? unlinkSync;
|
|
699
|
+
|
|
700
|
+
// ── Partial-adapter fail-closed, backup path only ──────────────────────────
|
|
701
|
+
//
|
|
702
|
+
// Per-method fallback to the real `node:fs` is the right default for the
|
|
703
|
+
// three ALWAYS-used methods: `board-writer.mjs#writeBoard` passes an fs object
|
|
704
|
+
// on EVERY call, including production, where `renameSync`/`copyFileSync` are
|
|
705
|
+
// present-but-`undefined` because nothing was injected. "An injected object
|
|
706
|
+
// must be total" would therefore reject the only real caller — the shape is
|
|
707
|
+
// not evidence of a fake.
|
|
708
|
+
//
|
|
709
|
+
// The backup path is different in kind. It runs ONLY under `backup: true`, and
|
|
710
|
+
// there a missing method routes a real `copyFileSync`/`existsSync` at the real
|
|
711
|
+
// filesystem while the write goes to the fake: a suite that believes itself
|
|
712
|
+
// hermetic drops `.bak-<ISO>` files into the repo, and nothing says so. Both
|
|
713
|
+
// methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
|
|
714
|
+
// the real target and silently decides the backup branch from it, which is the
|
|
715
|
+
// same escape one step earlier.
|
|
716
|
+
if (backup && injectedFs) {
|
|
717
|
+
for (const method of ['existsSync', 'copyFileSync']) {
|
|
718
|
+
if (typeof injectedFs[method] !== 'function') {
|
|
719
|
+
return {
|
|
720
|
+
ok: false,
|
|
721
|
+
reason: 'fs-error',
|
|
722
|
+
error: `partial fs adapter: ${method} required for backup`,
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
let tmpFile = null;
|
|
729
|
+
let tmpCreated = false;
|
|
730
|
+
|
|
568
731
|
try {
|
|
569
732
|
const dir = dirname(filePath);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
733
|
+
fsMkdir(dir, { recursive: true });
|
|
734
|
+
|
|
735
|
+
let backupPath = null;
|
|
736
|
+
if (backup && fsExists(filePath)) {
|
|
737
|
+
const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
|
|
738
|
+
backupPath = `${filePath}.bak-${stamp}`;
|
|
739
|
+
fsCopyFile(filePath, backupPath);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
tmpFile = path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
|
|
743
|
+
fsWriteFile(tmpFile, body, encoding);
|
|
744
|
+
tmpCreated = true;
|
|
745
|
+
fsRename(tmpFile, filePath);
|
|
746
|
+
|
|
747
|
+
return {
|
|
748
|
+
ok: true,
|
|
749
|
+
path: filePath,
|
|
750
|
+
bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
|
|
751
|
+
backupPath,
|
|
752
|
+
};
|
|
576
753
|
} catch (err) {
|
|
754
|
+
// Only when the write got far enough to create it. The name carries 12 hex
|
|
755
|
+
// chars of entropy, so this cannot collide with a caller's real file.
|
|
756
|
+
if (tmpCreated) {
|
|
757
|
+
try {
|
|
758
|
+
fsUnlink(tmpFile);
|
|
759
|
+
} catch {
|
|
760
|
+
// Best-effort: never let cleanup replace the error the caller needs.
|
|
761
|
+
}
|
|
762
|
+
}
|
|
577
763
|
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
578
764
|
}
|
|
579
765
|
}
|
|
@@ -52,6 +52,35 @@
|
|
|
52
52
|
* #1015 hardened for `.claude/rules/`. The primitives are imported, never
|
|
53
53
|
* re-implemented: a second copy is how this channel shipped raw beside the
|
|
54
54
|
* hardened one in the first place.
|
|
55
|
+
* - `extractProvenance()` from `../validate/check-learning-provenance.mjs` and
|
|
56
|
+
* `learningKeyOf()` from `./kebab.mjs` — the already-delivered filter below.
|
|
57
|
+
* Both readers already exist; a second provenance parser here would be the
|
|
58
|
+
* "same fact in two copies" defect this repo keeps paying for.
|
|
59
|
+
*
|
|
60
|
+
* ## Already-delivered filter (#1019)
|
|
61
|
+
*
|
|
62
|
+
* `/reconcile` converts a learning into a `.claude/rules/*.md` file, and Claude
|
|
63
|
+
* Code delivers every such file to every dispatched agent NATIVELY, in full.
|
|
64
|
+
* Measured first-person 2026-08-15 @fd73548+wave-2: a wave subagent's context
|
|
65
|
+
* carried all 29 of `.claude/rules/*.md` — `alwaysApply: false` and `globs:`
|
|
66
|
+
* notwithstanding, because `rule-loader.mjs` (the only code that understands
|
|
67
|
+
* that frontmatter) does not run on the delivery path
|
|
68
|
+
* (`docs/instruction-delivery.md` §1/§1.1). So a rule-derived learning that also
|
|
69
|
+
* enters this index arrives TWICE, and the second copy costs a slot in a
|
|
70
|
+
* 2000-char budget — displacing a learning the agent would otherwise never see.
|
|
71
|
+
*
|
|
72
|
+
* The filter therefore runs BEFORE the split caps, not after: dropping a
|
|
73
|
+
* duplicate after the Top-N cut would remove the line without freeing its slot,
|
|
74
|
+
* which is the whole harm. Two axes, mirroring the provenance checker's own
|
|
75
|
+
* `dangling` / `superseded` split: the rule's `learning-id` (exact record) and
|
|
76
|
+
* its `learning-key` (logical identity, stable across a re-minted UUID — the
|
|
77
|
+
* state any id backfill lands in). 13 of 29 rules carry provenance and all 13
|
|
78
|
+
* resolve by id today, so the key axis is currently inert by measurement, not
|
|
79
|
+
* by design.
|
|
80
|
+
*
|
|
81
|
+
* SILENT NO-OP is a hard requirement: no rules directory, no `.md` files, or no
|
|
82
|
+
* provenance block anywhere ⇒ empty sets ⇒ byte-identical output. A repo without
|
|
83
|
+
* `/reconcile` must never see FEWER learnings because this filter exists.
|
|
55
84
|
*
|
|
56
85
|
* NOT used: `filterByScope()` from `./filters.mjs`. Despite the name it filters
|
|
57
86
|
* the PRIVACY enum `['local','private','public']` (schema.mjs), not file scope.
|
|
@@ -96,12 +125,17 @@
|
|
|
96
125
|
* 5. Expired and sub-floor entries are never selected.
|
|
97
126
|
*/
|
|
98
127
|
|
|
128
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
129
|
+
import { join } from 'node:path';
|
|
130
|
+
|
|
99
131
|
import { affinity } from './affinity.mjs';
|
|
100
132
|
import {
|
|
101
133
|
INSIGHT_MAX_BYTES,
|
|
102
134
|
TITLE_MAX_BYTES,
|
|
103
135
|
sanitizeProse,
|
|
104
136
|
} from '../reconcile/sanitize.mjs';
|
|
137
|
+
import { learningKeyOf } from './kebab.mjs';
|
|
138
|
+
import { extractProvenance } from '../validate/check-learning-provenance.mjs';
|
|
105
139
|
import { DECAY_DEFAULTS, effectiveScore, surfaceTopN } from './surface.mjs';
|
|
106
140
|
|
|
107
141
|
// ---------------------------------------------------------------------------
|
|
@@ -178,9 +212,15 @@ export const DEFAULT_CONFIDENCE_FLOOR = 0.3;
|
|
|
178
212
|
* @typedef {{entries: object[], selected: SelectedLearning[], lines: string[],
|
|
179
213
|
* text: string, chars: number, scopeMatched: number,
|
|
180
214
|
* globalCount: number, candidates: number, truncated: boolean,
|
|
181
|
-
* rejected: number}} Selection
|
|
215
|
+
* rejected: number, deliveredFiltered: number}} Selection
|
|
182
216
|
* `rejected` counts records dropped by the untrusted-text guard — surfaced so
|
|
183
217
|
* a drop is observable in the injection event rather than silent.
|
|
218
|
+
* `deliveredFiltered` counts records dropped because a `.claude/rules/*.md`
|
|
219
|
+
* file already delivers them natively (#1019) — same reason, and it is the
|
|
220
|
+
* only way to tell "the filter bit" from "the corpus has no such learning".
|
|
221
|
+
*
|
|
222
|
+
* @typedef {{ids: Set<string>, keys: Set<string>}} DeliveredProvenance
|
|
223
|
+
* Learning ids and logical keys already delivered as `.claude/rules/*.md`.
|
|
184
224
|
*/
|
|
185
225
|
|
|
186
226
|
// ---------------------------------------------------------------------------
|
|
@@ -214,6 +254,43 @@ function _isActive(entry, nowMs, confidenceFloor) {
|
|
|
214
254
|
return true;
|
|
215
255
|
}
|
|
216
256
|
|
|
257
|
+
/**
|
|
258
|
+
* Normalize a caller-supplied delivered-provenance option into two Sets, or
|
|
259
|
+
* `null` when there is nothing to filter against.
|
|
260
|
+
*
|
|
261
|
+
* `null` (not empty Sets) is the no-filter signal so the hot loop can skip the
|
|
262
|
+
* per-entry `learningKeyOf()` call entirely — and so the SILENT NO-OP guarantee
|
|
263
|
+
* is one explicit branch rather than an emergent property of empty membership.
|
|
264
|
+
*
|
|
265
|
+
* @param {unknown} v `{ids, keys}` with Sets or arrays; anything else ⇒ `null`
|
|
266
|
+
* @returns {DeliveredProvenance|null}
|
|
267
|
+
*/
|
|
268
|
+
function _resolveDelivered(v) {
|
|
269
|
+
if (!_isRecord(v)) return null;
|
|
270
|
+
const toSet = (x) => (x instanceof Set ? x : Array.isArray(x) ? new Set(x) : new Set());
|
|
271
|
+
const ids = toSet(v.ids);
|
|
272
|
+
const keys = toSet(v.keys);
|
|
273
|
+
return ids.size === 0 && keys.size === 0 ? null : { ids, keys };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* True when this learning already reaches the agent as a natively-delivered
|
|
278
|
+
* `.claude/rules/*.md` file.
|
|
279
|
+
*
|
|
280
|
+
* Id first (exact record), then the logical key — a rule whose `learning-id`
|
|
281
|
+
* was re-minted by a backfill still delivers the same content, which is the
|
|
282
|
+
* `superseded-learning-id` state `check-learning-provenance.mjs` names.
|
|
283
|
+
*
|
|
284
|
+
* @param {object} entry
|
|
285
|
+
* @param {DeliveredProvenance} delivered
|
|
286
|
+
* @returns {boolean}
|
|
287
|
+
*/
|
|
288
|
+
function _isDelivered(entry, delivered) {
|
|
289
|
+
if (typeof entry.id === 'string' && entry.id !== '' && delivered.ids.has(entry.id)) return true;
|
|
290
|
+
const key = learningKeyOf(entry); // total: a shape-foreign entry yields null
|
|
291
|
+
return key !== null && delivered.keys.has(key);
|
|
292
|
+
}
|
|
293
|
+
|
|
217
294
|
/** Epoch ms from a Date | number | undefined clock option. */
|
|
218
295
|
function _resolveNowMs(now) {
|
|
219
296
|
if (now instanceof Date) return now.getTime();
|
|
@@ -274,9 +351,60 @@ export function emptySelection() {
|
|
|
274
351
|
candidates: 0,
|
|
275
352
|
truncated: false,
|
|
276
353
|
rejected: 0,
|
|
354
|
+
deliveredFiltered: 0,
|
|
277
355
|
};
|
|
278
356
|
}
|
|
279
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Read the provenance pointers of every `.claude/rules/*.md` — the set of
|
|
360
|
+
* learnings the agent already receives natively (#1019).
|
|
361
|
+
*
|
|
362
|
+
* Reuses `extractProvenance()` rather than re-deriving the block format. Total
|
|
363
|
+
* by construction: an absent, unreadable or provenance-free directory yields
|
|
364
|
+
* empty sets, which {@link selectLearnings} reads as "no filter" (SILENT NO-OP).
|
|
365
|
+
*
|
|
366
|
+
* Read discipline mirrors `check-learning-provenance.mjs`: `readFileSync`, never
|
|
367
|
+
* a `grep` spawn — one NUL byte makes a text file invisible to a grep-based
|
|
368
|
+
* audit, and a silently-skipped rule file reads exactly like a rule with no
|
|
369
|
+
* provenance.
|
|
370
|
+
*
|
|
371
|
+
* Ceiling: one synchronous read per rule file, linear in the corpus (29 files /
|
|
372
|
+
* ~170 KB today). Revisit — cache per process or read async — if
|
|
373
|
+
* `.claude/rules/` passes a few hundred files.
|
|
374
|
+
*
|
|
375
|
+
* @param {string} rulesDir absolute path to the `.claude/rules` directory
|
|
376
|
+
* @returns {DeliveredProvenance}
|
|
377
|
+
*/
|
|
378
|
+
export function readDeliveredProvenance(rulesDir) {
|
|
379
|
+
/** @type {Set<string>} */
|
|
380
|
+
const ids = new Set();
|
|
381
|
+
/** @type {Set<string>} */
|
|
382
|
+
const keys = new Set();
|
|
383
|
+
if (typeof rulesDir !== 'string' || rulesDir.trim() === '') return { ids, keys };
|
|
384
|
+
|
|
385
|
+
/** @type {string[]} */
|
|
386
|
+
let names;
|
|
387
|
+
try {
|
|
388
|
+
names = readdirSync(rulesDir);
|
|
389
|
+
} catch {
|
|
390
|
+
return { ids, keys }; // no rules directory ⇒ nothing is natively delivered
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const name of names) {
|
|
394
|
+
if (!name.endsWith('.md')) continue;
|
|
395
|
+
let body;
|
|
396
|
+
try {
|
|
397
|
+
body = readFileSync(join(rulesDir, name), 'utf8');
|
|
398
|
+
} catch {
|
|
399
|
+
continue; // one unreadable rule must not cost the whole census
|
|
400
|
+
}
|
|
401
|
+
const { id, key } = extractProvenance(body);
|
|
402
|
+
if (id) ids.add(id);
|
|
403
|
+
if (key) keys.add(key);
|
|
404
|
+
}
|
|
405
|
+
return { ids, keys };
|
|
406
|
+
}
|
|
407
|
+
|
|
280
408
|
/**
|
|
281
409
|
* Truncate to at most `maxUnits` UTF-16 code units, cutting on a CODE-POINT
|
|
282
410
|
* boundary.
|
|
@@ -413,6 +541,9 @@ export function scoreLearning(entry, scope, opts = {}) {
|
|
|
413
541
|
* @param {Date|number} [opts.now] — injectable clock
|
|
414
542
|
* @param {object} [opts.decay] — #670 decay tuning, forwarded to effectiveScore
|
|
415
543
|
* @param {object} [opts.affinityOpts] — forwarded to affinity()
|
|
544
|
+
* @param {DeliveredProvenance} [opts.delivered] — learnings already delivered
|
|
545
|
+
* natively as `.claude/rules/*.md` (#1019); omitted/empty ⇒ no filtering, and
|
|
546
|
+
* the selection is byte-identical to the pre-filter one
|
|
416
547
|
* @returns {Selection}
|
|
417
548
|
*/
|
|
418
549
|
export function selectLearnings(entries, scope, opts = {}) {
|
|
@@ -433,6 +564,7 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
433
564
|
? o.confidenceFloor
|
|
434
565
|
: DEFAULT_CONFIDENCE_FLOOR;
|
|
435
566
|
const nowMs = _resolveNowMs(o.now);
|
|
567
|
+
const delivered = _resolveDelivered(o.delivered);
|
|
436
568
|
const scoreOpts = { now: nowMs, decay: o.decay, affinityOpts: o.affinityOpts };
|
|
437
569
|
|
|
438
570
|
/** @type {SelectedLearning[]} */
|
|
@@ -441,12 +573,23 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
441
573
|
const global = [];
|
|
442
574
|
let candidates = 0;
|
|
443
575
|
let rejected = 0;
|
|
576
|
+
let deliveredFiltered = 0;
|
|
444
577
|
|
|
445
578
|
for (const entry of entries) {
|
|
446
579
|
if (!_isRecord(entry)) continue;
|
|
447
580
|
if (!_isActive(entry, nowMs, confidenceFloor)) continue;
|
|
448
581
|
candidates++;
|
|
449
582
|
|
|
583
|
+
// #1019 — BEFORE the split caps below, never after. This entry already
|
|
584
|
+
// reaches the agent in full as a `.claude/rules/*.md` file; dropping it
|
|
585
|
+
// here frees its slot for a learning the agent would otherwise never see,
|
|
586
|
+
// whereas dropping it after the Top-N cut would only shorten the index.
|
|
587
|
+
// `candidates` still counts it: the pool it was drawn from is unchanged.
|
|
588
|
+
if (delivered !== null && _isDelivered(entry, delivered)) {
|
|
589
|
+
deliveredFiltered++;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
450
593
|
const s = scoreLearning(entry, scope, scoreOpts);
|
|
451
594
|
// Fail CLOSED per entry: a record whose text forges the delivery wrapper
|
|
452
595
|
// is dropped, not neutralised in place — with 100 candidates competing for
|
|
@@ -510,6 +653,7 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
510
653
|
candidates,
|
|
511
654
|
truncated,
|
|
512
655
|
rejected,
|
|
656
|
+
deliveredFiltered,
|
|
513
657
|
};
|
|
514
658
|
} catch {
|
|
515
659
|
// Contract point 1 — a ranking primitive on the dispatch hot path must
|
|
@@ -525,7 +669,11 @@ export function selectLearnings(entries, scope, opts = {}) {
|
|
|
525
669
|
* @param {string} filePath — absolute path to learnings.jsonl
|
|
526
670
|
* @param {AgentScope} scope
|
|
527
671
|
* @param {object} [opts] — everything {@link selectLearnings} accepts, plus
|
|
528
|
-
* `poolSize` (how many active entries to pull before ranking)
|
|
672
|
+
* `poolSize` (how many active entries to pull before ranking) and `rulesDir`
|
|
673
|
+
* (absolute `.claude/rules` path; read via {@link readDeliveredProvenance} into
|
|
674
|
+
* the `delivered` filter). `rulesDir` is EXPLICIT rather than derived from
|
|
675
|
+
* `filePath`: guessing a repo root from a metrics path is the hand-maintained
|
|
676
|
+
* fact this repo keeps getting wrong. Omit it and nothing is filtered.
|
|
529
677
|
* @returns {Promise<Selection>} `emptySelection()` on a missing/unreadable file
|
|
530
678
|
*/
|
|
531
679
|
export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
@@ -538,12 +686,18 @@ export async function selectLearningsFromFile(filePath, scope, opts = {}) {
|
|
|
538
686
|
? o.confidenceFloor
|
|
539
687
|
: DEFAULT_CONFIDENCE_FLOOR;
|
|
540
688
|
|
|
689
|
+
const delivered = _isRecord(o.delivered)
|
|
690
|
+
? o.delivered
|
|
691
|
+
: typeof o.rulesDir === 'string' && o.rulesDir.trim() !== ''
|
|
692
|
+
? readDeliveredProvenance(o.rulesDir)
|
|
693
|
+
: undefined;
|
|
694
|
+
|
|
541
695
|
const entries = await surfaceTopN(filePath, poolSize, {
|
|
542
696
|
now: nowMs,
|
|
543
697
|
confidenceFloor,
|
|
544
698
|
decay: o.decay,
|
|
545
699
|
});
|
|
546
|
-
return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor });
|
|
700
|
+
return selectLearnings(entries, scope, { ...o, now: nowMs, confidenceFloor, delivered });
|
|
547
701
|
} catch {
|
|
548
702
|
return emptySelection();
|
|
549
703
|
}
|
|
@@ -1,20 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* memory-cleanup-stamp.mjs —
|
|
3
|
-
*
|
|
2
|
+
* memory-cleanup-stamp.mjs — stamp `memory_cleanup_at` on a session record when
|
|
3
|
+
* `/memory-cleanup` ran this session, and DERIVE that fact from the event log
|
|
4
|
+
* instead of from coordinator recall.
|
|
4
5
|
*
|
|
5
6
|
* Issue #699 fix: a healthy no-op run of `/memory-cleanup` previously left
|
|
6
7
|
* `memory_cleanup_at` unstamped, so `auto-dream.mjs` `readDreamSignals`
|
|
7
8
|
* never advanced `lastCleanupAt` and `shouldDispatchAutoDream` kept firing a
|
|
8
|
-
* false nudge.
|
|
9
|
-
* cleanup ran — including a healthy no-op where no memory files were mutated.
|
|
9
|
+
* false nudge. `stampMemoryCleanup()` stamps the field unconditionally whenever
|
|
10
|
+
* the cleanup ran — including a healthy no-op where no memory files were mutated.
|
|
11
|
+
*
|
|
12
|
+
* #699 follow-up (Disziplin statt Mechanik): the `ranCleanup` boolean itself used
|
|
13
|
+
* to come from the coordinator-LLM remembering a prose step at session-end. It
|
|
14
|
+
* measurably failed — a `/memory-cleanup` ran on 2026-08-14 and all three session
|
|
15
|
+
* records of that day read `memory_cleanup_at: null`, so the session-start banner
|
|
16
|
+
* reported "29 days ago" while the operator's own notes said 3. Since then
|
|
17
|
+
* `/memory-cleanup` emits `orchestrator.memory.cleanup_completed` and
|
|
18
|
+
* `deriveMemoryCleanupSignal()` reads that event back out of
|
|
19
|
+
* `.orchestrator/metrics/events.jsonl`, which is what `scripts/emit-session.mjs`
|
|
20
|
+
* calls at write time. Nothing depends on recall any more.
|
|
10
21
|
*
|
|
11
22
|
* Design constraints:
|
|
12
|
-
* -
|
|
13
|
-
* - No-throw — invalid inputs
|
|
14
|
-
*
|
|
15
|
-
* -
|
|
23
|
+
* - `stampMemoryCleanup()` stays pure — no I/O, no side-effects.
|
|
24
|
+
* - No-throw — invalid inputs / unreadable-or-corrupt event log return the
|
|
25
|
+
* "no cleanup" answer rather than blocking a session close.
|
|
26
|
+
* - Testable seam — both functions are unit-tested directly.
|
|
27
|
+
* - No external deps — Node stdlib only.
|
|
16
28
|
*/
|
|
17
29
|
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Event `/memory-cleanup` emits when a run completes (any mode, any outcome —
|
|
34
|
+
* including a healthy no-op). Emitted via `scripts/emit-event.mjs` → the
|
|
35
|
+
* canonical `emitEvent()` path; see `skills/memory-cleanup/SKILL.md` § Output.
|
|
36
|
+
*/
|
|
37
|
+
export const MEMORY_CLEANUP_EVENT = 'orchestrator.memory.cleanup_completed';
|
|
38
|
+
|
|
18
39
|
/**
|
|
19
40
|
* Stamp `memory_cleanup_at` on a session record when `/memory-cleanup` ran.
|
|
20
41
|
*
|
|
@@ -54,3 +75,106 @@ export function stampMemoryCleanup(record, { ranCleanup, completedAt } = {}) {
|
|
|
54
75
|
// Return a shallow clone with the stamp applied — never mutate the input.
|
|
55
76
|
return { ...record, memory_cleanup_at: completedAt };
|
|
56
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Derive "did `/memory-cleanup` run in THIS session?" from the event log.
|
|
81
|
+
*
|
|
82
|
+
* Reads `eventsFile` and looks for `orchestrator.memory.cleanup_completed`
|
|
83
|
+
* records that fall inside the session's own `[startedAt, completedAt]` window.
|
|
84
|
+
* This is the mechanical replacement for the coordinator-supplied boolean.
|
|
85
|
+
*
|
|
86
|
+
* Matching rule (two clauses, both mechanical):
|
|
87
|
+
* 1. the event's `timestamp` lies within `[startedAt, completedAt]` inclusive;
|
|
88
|
+
* 2. IF the event carries a non-empty `semantic_session_id`, it must equal
|
|
89
|
+
* `sessionId` (sessions.jsonl `session_id` is the SEMANTIC id — the UUID
|
|
90
|
+
* `session_id` some events carry lives in a different id space and is
|
|
91
|
+
* deliberately NOT used for matching).
|
|
92
|
+
*
|
|
93
|
+
* Named ceiling (deliberate simplification): an event with NO attribution is
|
|
94
|
+
* claimed on the window alone, so two parallel sessions in one repo with
|
|
95
|
+
* overlapping windows both derive `ranCleanup: true`. The consequence is a
|
|
96
|
+
* marginally generous cadence marker on the peer's record — never a MISSED
|
|
97
|
+
* cleanup, which is the failure this whole path exists to prevent. Revisit
|
|
98
|
+
* trigger: if per-session cleanup accounting is ever needed, attach
|
|
99
|
+
* `semantic_session_id` to the emitted event and clause 2 makes it exact.
|
|
100
|
+
* Scan cost is a single linear pass over `events.jsonl` (~23k lines,
|
|
101
|
+
* size-rotated at SessionStart by `events-rotation.mjs`) — revisit if rotation
|
|
102
|
+
* is ever removed.
|
|
103
|
+
*
|
|
104
|
+
* No-throw: a missing, unreadable, or partly-corrupt event log yields
|
|
105
|
+
* `{ ranCleanup: false, ... }`. A session close must never fail because
|
|
106
|
+
* telemetry is damaged.
|
|
107
|
+
*
|
|
108
|
+
* @param {object} opts
|
|
109
|
+
* @param {string} opts.eventsFile Absolute or CWD-relative path to events.jsonl.
|
|
110
|
+
* @param {string} [opts.sessionId] The record's `session_id` (semantic form).
|
|
111
|
+
* @param {string} opts.startedAt Session `started_at` (ISO-8601).
|
|
112
|
+
* @param {string} opts.completedAt Session `completed_at` (ISO-8601).
|
|
113
|
+
* @returns {{ranCleanup: boolean, at: string|null, matches: number}}
|
|
114
|
+
* `at` is the LATEST matching event timestamp (null when none matched);
|
|
115
|
+
* `matches` is how many events matched (a run can emit more than one).
|
|
116
|
+
*/
|
|
117
|
+
export function deriveMemoryCleanupSignal({
|
|
118
|
+
eventsFile,
|
|
119
|
+
sessionId,
|
|
120
|
+
startedAt,
|
|
121
|
+
completedAt,
|
|
122
|
+
} = {}) {
|
|
123
|
+
const none = { ranCleanup: false, at: null, matches: 0 };
|
|
124
|
+
|
|
125
|
+
if (typeof eventsFile !== 'string' || eventsFile.length === 0) return none;
|
|
126
|
+
|
|
127
|
+
const startMs = Date.parse(startedAt);
|
|
128
|
+
const endMs = Date.parse(completedAt);
|
|
129
|
+
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) {
|
|
130
|
+
return none;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let raw;
|
|
134
|
+
try {
|
|
135
|
+
raw = readFileSync(eventsFile, 'utf8');
|
|
136
|
+
} catch {
|
|
137
|
+
// Missing / unreadable event log — no signal, never an error.
|
|
138
|
+
return none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const wantSession = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
142
|
+
let matches = 0;
|
|
143
|
+
let latestMs = -Infinity;
|
|
144
|
+
let latestTs = null;
|
|
145
|
+
|
|
146
|
+
for (const line of raw.split('\n')) {
|
|
147
|
+
// Cheap substring prefilter before the JSON.parse cost.
|
|
148
|
+
if (line.length === 0 || !line.includes(MEMORY_CLEANUP_EVENT)) continue;
|
|
149
|
+
|
|
150
|
+
let rec;
|
|
151
|
+
try {
|
|
152
|
+
rec = JSON.parse(line);
|
|
153
|
+
} catch {
|
|
154
|
+
continue; // corrupt line — skip, best-effort reader
|
|
155
|
+
}
|
|
156
|
+
if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) continue;
|
|
157
|
+
if (rec.event !== MEMORY_CLEANUP_EVENT) continue;
|
|
158
|
+
|
|
159
|
+
const tsMs = Date.parse(rec.timestamp);
|
|
160
|
+
if (!Number.isFinite(tsMs) || tsMs < startMs || tsMs > endMs) continue;
|
|
161
|
+
|
|
162
|
+
const semantic = rec.semantic_session_id;
|
|
163
|
+
if (
|
|
164
|
+
wantSession !== null &&
|
|
165
|
+
typeof semantic === 'string' &&
|
|
166
|
+
semantic.length > 0 &&
|
|
167
|
+
semantic !== wantSession
|
|
168
|
+
) {
|
|
169
|
+
continue; // attributed to a DIFFERENT session — not ours
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
matches += 1;
|
|
173
|
+
if (tsMs > latestMs) {
|
|
174
|
+
latestMs = tsMs;
|
|
175
|
+
latestTs = rec.timestamp;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { ranCleanup: matches > 0, at: latestTs, matches };
|
|
180
|
+
}
|