wotann 0.5.85 → 0.5.86
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.
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wotann skills curator <verb>` — thin CLI shell around skill-curator.
|
|
3
|
+
*
|
|
4
|
+
* Verbs:
|
|
5
|
+
* status — render scheduler state + per-skill lifecycle
|
|
6
|
+
* run [--dry-run] — execute a pass (or preview without mutating)
|
|
7
|
+
* pause / resume — toggle the paused flag
|
|
8
|
+
* pin <name> / unpin <name>— bypass auto-transitions for a specific skill
|
|
9
|
+
*
|
|
10
|
+
* No side effects at import time. The src/index.ts skillsCmd dispatcher
|
|
11
|
+
* lazy-imports this module so the CLI cold path stays slim.
|
|
12
|
+
*/
|
|
13
|
+
import { applyAutomaticTransitions, type CuratorState } from "../../learning/skill-curator.js";
|
|
14
|
+
export interface StatusOptions {
|
|
15
|
+
/** Inject a writer for testability. Defaults to console.log. */
|
|
16
|
+
readonly write?: (line: string) => void;
|
|
17
|
+
/** Anchor "now" for the time-ago math. */
|
|
18
|
+
readonly now?: Date;
|
|
19
|
+
}
|
|
20
|
+
export declare function runStatus(opts?: StatusOptions): number;
|
|
21
|
+
export interface RunOptions {
|
|
22
|
+
readonly dryRun?: boolean;
|
|
23
|
+
readonly write?: (line: string) => void;
|
|
24
|
+
readonly now?: Date;
|
|
25
|
+
}
|
|
26
|
+
export declare function runRun(opts?: RunOptions): number;
|
|
27
|
+
export declare function runPause(opts?: {
|
|
28
|
+
write?: (line: string) => void;
|
|
29
|
+
}): number;
|
|
30
|
+
export declare function runResume(opts?: {
|
|
31
|
+
write?: (line: string) => void;
|
|
32
|
+
}): number;
|
|
33
|
+
export declare function runPin(skillName: string, opts?: {
|
|
34
|
+
write?: (line: string) => void;
|
|
35
|
+
}): number;
|
|
36
|
+
export declare function runUnpin(skillName: string, opts?: {
|
|
37
|
+
write?: (line: string) => void;
|
|
38
|
+
}): number;
|
|
39
|
+
/**
|
|
40
|
+
* Daemon-friendly entry point: applies auto-transitions WITHOUT
|
|
41
|
+
* rendering anything. Returns counts so the caller can log / emit
|
|
42
|
+
* telemetry on its own terms.
|
|
43
|
+
*
|
|
44
|
+
* Used by the (future) kairos idle-detector wire. Manual CLI runs
|
|
45
|
+
* go through {@link runRun} which writes the summary string.
|
|
46
|
+
*/
|
|
47
|
+
export declare function runSilentPass(now?: Date): {
|
|
48
|
+
readonly transitions: ReturnType<typeof applyAutomaticTransitions>;
|
|
49
|
+
readonly stateAfter: CuratorState;
|
|
50
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wotann skills curator <verb>` — thin CLI shell around skill-curator.
|
|
3
|
+
*
|
|
4
|
+
* Verbs:
|
|
5
|
+
* status — render scheduler state + per-skill lifecycle
|
|
6
|
+
* run [--dry-run] — execute a pass (or preview without mutating)
|
|
7
|
+
* pause / resume — toggle the paused flag
|
|
8
|
+
* pin <name> / unpin <name>— bypass auto-transitions for a specific skill
|
|
9
|
+
*
|
|
10
|
+
* No side effects at import time. The src/index.ts skillsCmd dispatcher
|
|
11
|
+
* lazy-imports this module so the CLI cold path stays slim.
|
|
12
|
+
*/
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
import { applyAutomaticTransitions, isPaused, listAgentSkills, loadState, runCurator, saveState, setPaused, setPinned, } from "../../learning/skill-curator.js";
|
|
15
|
+
// ── Time-ago formatter (Hermes-style) ─────────────────────────────
|
|
16
|
+
function formatTimeAgo(iso, now = new Date()) {
|
|
17
|
+
if (iso === null)
|
|
18
|
+
return "never";
|
|
19
|
+
const ms = now.getTime() - new Date(iso).getTime();
|
|
20
|
+
if (!Number.isFinite(ms))
|
|
21
|
+
return "unknown";
|
|
22
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
23
|
+
if (seconds < 60)
|
|
24
|
+
return `${seconds}s ago`;
|
|
25
|
+
if (seconds < 3600)
|
|
26
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
27
|
+
if (seconds < 86_400)
|
|
28
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
29
|
+
return `${Math.floor(seconds / 86_400)}d ago`;
|
|
30
|
+
}
|
|
31
|
+
export function runStatus(opts = {}) {
|
|
32
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
33
|
+
const now = opts.now ?? new Date();
|
|
34
|
+
const state = loadState();
|
|
35
|
+
const skills = listAgentSkills();
|
|
36
|
+
write(chalk.bold(formatHeader(state)));
|
|
37
|
+
write(` runs: ${state.runCount}`);
|
|
38
|
+
write(` last run: ${formatTimeAgo(state.lastRunAt, now)}`);
|
|
39
|
+
write(` last summary: ${state.lastRunSummary ?? "(none)"}`);
|
|
40
|
+
if (state.lastRunDurationSeconds !== null) {
|
|
41
|
+
write(` last duration: ${state.lastRunDurationSeconds}s`);
|
|
42
|
+
}
|
|
43
|
+
const buckets = {
|
|
44
|
+
active: [],
|
|
45
|
+
stale: [],
|
|
46
|
+
archived: [],
|
|
47
|
+
};
|
|
48
|
+
const pinned = [];
|
|
49
|
+
for (const skill of skills) {
|
|
50
|
+
buckets[skill.state].push(skill);
|
|
51
|
+
if (skill.pinned)
|
|
52
|
+
pinned.push(skill.name);
|
|
53
|
+
}
|
|
54
|
+
write("");
|
|
55
|
+
if (skills.length === 0) {
|
|
56
|
+
write(chalk.dim(" no agent-created skills"));
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
write(` agent-created skills: ${skills.length}`);
|
|
60
|
+
for (const state_ of ["active", "stale", "archived"]) {
|
|
61
|
+
write(` ${state_.padEnd(10)} ${buckets[state_].length}`);
|
|
62
|
+
}
|
|
63
|
+
if (pinned.length > 0) {
|
|
64
|
+
write("");
|
|
65
|
+
write(` pinned (${pinned.length}): ${pinned.join(", ")}`);
|
|
66
|
+
}
|
|
67
|
+
const leastActive = [...buckets.active]
|
|
68
|
+
.sort((a, b) => {
|
|
69
|
+
const aT = a.lastActivityAt ?? a.createdAt;
|
|
70
|
+
const bT = b.lastActivityAt ?? b.createdAt;
|
|
71
|
+
return aT.localeCompare(bT);
|
|
72
|
+
})
|
|
73
|
+
.slice(0, 5);
|
|
74
|
+
if (leastActive.length > 0) {
|
|
75
|
+
write("");
|
|
76
|
+
write(" least recently active (top 5):");
|
|
77
|
+
for (const skill of leastActive) {
|
|
78
|
+
write(` ${skill.name.padEnd(40)} ` +
|
|
79
|
+
`use=${String(skill.useCount).padStart(3)} ` +
|
|
80
|
+
`view=${String(skill.viewCount).padStart(3)} ` +
|
|
81
|
+
`last=${formatTimeAgo(skill.lastActivityAt, now)}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
function formatHeader(state) {
|
|
87
|
+
if (state.paused)
|
|
88
|
+
return "curator: PAUSED";
|
|
89
|
+
return "curator: ENABLED";
|
|
90
|
+
}
|
|
91
|
+
export function runRun(opts = {}) {
|
|
92
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
93
|
+
if (isPaused() && opts.dryRun !== true) {
|
|
94
|
+
write(chalk.yellow("curator is paused. Resume with `wotann skills curator resume`."));
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const result = runCurator(opts.now ?? new Date(), { dryRun: opts.dryRun === true });
|
|
98
|
+
write(chalk.bold(result.dryRun ? "DRY RUN — no mutations" : "Curator pass"));
|
|
99
|
+
write(` ${result.summary}`);
|
|
100
|
+
write(` duration: ${result.durationSeconds.toFixed(2)}s`);
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
// ── pause / resume ────────────────────────────────────────────────
|
|
104
|
+
export function runPause(opts = {}) {
|
|
105
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
106
|
+
setPaused(true);
|
|
107
|
+
write(chalk.yellow("curator paused"));
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
export function runResume(opts = {}) {
|
|
111
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
112
|
+
setPaused(false);
|
|
113
|
+
write(chalk.green("curator resumed"));
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
// ── pin / unpin ───────────────────────────────────────────────────
|
|
117
|
+
export function runPin(skillName, opts = {}) {
|
|
118
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
119
|
+
if (skillName.length === 0) {
|
|
120
|
+
write(chalk.red("usage: wotann skills curator pin <name>"));
|
|
121
|
+
return 2;
|
|
122
|
+
}
|
|
123
|
+
if (setPinned(skillName, true)) {
|
|
124
|
+
write(chalk.green(`pinned: ${skillName}`));
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
write(chalk.red(`no agent-created skill on disk: ${skillName}`));
|
|
128
|
+
return 1;
|
|
129
|
+
}
|
|
130
|
+
export function runUnpin(skillName, opts = {}) {
|
|
131
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
132
|
+
if (skillName.length === 0) {
|
|
133
|
+
write(chalk.red("usage: wotann skills curator unpin <name>"));
|
|
134
|
+
return 2;
|
|
135
|
+
}
|
|
136
|
+
if (setPinned(skillName, false)) {
|
|
137
|
+
write(chalk.green(`unpinned: ${skillName}`));
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
write(chalk.red(`no agent-created skill on disk: ${skillName}`));
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
// ── transition-only (kairos / daemon entry point) ─────────────────
|
|
144
|
+
/**
|
|
145
|
+
* Daemon-friendly entry point: applies auto-transitions WITHOUT
|
|
146
|
+
* rendering anything. Returns counts so the caller can log / emit
|
|
147
|
+
* telemetry on its own terms.
|
|
148
|
+
*
|
|
149
|
+
* Used by the (future) kairos idle-detector wire. Manual CLI runs
|
|
150
|
+
* go through {@link runRun} which writes the summary string.
|
|
151
|
+
*/
|
|
152
|
+
export function runSilentPass(now = new Date()) {
|
|
153
|
+
const transitions = applyAutomaticTransitions(now);
|
|
154
|
+
const prev = loadState();
|
|
155
|
+
saveState({
|
|
156
|
+
...prev,
|
|
157
|
+
lastRunAt: now.toISOString(),
|
|
158
|
+
lastRunSummary: `silent: checked ${transitions.checked}, archived ${transitions.archived}`,
|
|
159
|
+
runCount: prev.runCount + 1,
|
|
160
|
+
});
|
|
161
|
+
return { transitions, stateAfter: loadState() };
|
|
162
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3578,6 +3578,58 @@ skillsCmd
|
|
|
3578
3578
|
if (out.exitCode !== 0)
|
|
3579
3579
|
process.exit(out.exitCode);
|
|
3580
3580
|
});
|
|
3581
|
+
// ── wotann skills curator ────────────────────────────────────
|
|
3582
|
+
//
|
|
3583
|
+
// Hermes Gap 1 port — inactivity-triggered lifecycle manager for
|
|
3584
|
+
// agent-created skills. State machine lives in src/learning/
|
|
3585
|
+
// skill-curator.ts; this is the thin CLI wrapper. See the module
|
|
3586
|
+
// docblock there for the full lifecycle / invariants discussion.
|
|
3587
|
+
const curatorCmd = skillsCmd
|
|
3588
|
+
.command("curator")
|
|
3589
|
+
.description("Manage the inactivity-triggered skill lifecycle (active/stale/archived)");
|
|
3590
|
+
curatorCmd
|
|
3591
|
+
.command("status")
|
|
3592
|
+
.description("Show curator scheduler state + per-skill lifecycle counts")
|
|
3593
|
+
.action(async () => {
|
|
3594
|
+
const mod = await import("./cli/commands/skills-curator.js");
|
|
3595
|
+
process.exit(mod.runStatus());
|
|
3596
|
+
});
|
|
3597
|
+
curatorCmd
|
|
3598
|
+
.command("run")
|
|
3599
|
+
.description("Execute one curator pass (apply auto-transitions)")
|
|
3600
|
+
.option("--dry-run", "Compute would-be transitions without mutating the library")
|
|
3601
|
+
.action(async (opts) => {
|
|
3602
|
+
const mod = await import("./cli/commands/skills-curator.js");
|
|
3603
|
+
process.exit(mod.runRun({ dryRun: opts.dryRun === true }));
|
|
3604
|
+
});
|
|
3605
|
+
curatorCmd
|
|
3606
|
+
.command("pause")
|
|
3607
|
+
.description("Pause the curator (scheduler will not run automatic passes)")
|
|
3608
|
+
.action(async () => {
|
|
3609
|
+
const mod = await import("./cli/commands/skills-curator.js");
|
|
3610
|
+
process.exit(mod.runPause());
|
|
3611
|
+
});
|
|
3612
|
+
curatorCmd
|
|
3613
|
+
.command("resume")
|
|
3614
|
+
.description("Resume the curator after a pause")
|
|
3615
|
+
.action(async () => {
|
|
3616
|
+
const mod = await import("./cli/commands/skills-curator.js");
|
|
3617
|
+
process.exit(mod.runResume());
|
|
3618
|
+
});
|
|
3619
|
+
curatorCmd
|
|
3620
|
+
.command("pin <name>")
|
|
3621
|
+
.description("Pin a skill so it bypasses auto-transitions (stale + archive)")
|
|
3622
|
+
.action(async (name) => {
|
|
3623
|
+
const mod = await import("./cli/commands/skills-curator.js");
|
|
3624
|
+
process.exit(mod.runPin(name));
|
|
3625
|
+
});
|
|
3626
|
+
curatorCmd
|
|
3627
|
+
.command("unpin <name>")
|
|
3628
|
+
.description("Unpin a skill so it resumes auto-transitions")
|
|
3629
|
+
.action(async (name) => {
|
|
3630
|
+
const mod = await import("./cli/commands/skills-curator.js");
|
|
3631
|
+
process.exit(mod.runUnpin(name));
|
|
3632
|
+
});
|
|
3581
3633
|
// ── wotann cost ──────────────────────────────────────────────
|
|
3582
3634
|
//
|
|
3583
3635
|
// Wave 4G: the cost command now accepts an optional `period` argument
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Curator — inactivity-triggered skill lifecycle manager.
|
|
3
|
+
*
|
|
4
|
+
* Port of Hermes' `agent/curator.py` (NousResearch/hermes-agent, MIT).
|
|
5
|
+
* The Hermes curator runs background reviews of agent-created skills,
|
|
6
|
+
* auto-transitioning them between lifecycle states (active → stale →
|
|
7
|
+
* archived) based on real activity timestamps, and pinning bypasses
|
|
8
|
+
* the auto-transitions entirely. Hermes' full curator also spawns a
|
|
9
|
+
* forked AIAgent to do LLM-graded review; we ship the pure-function
|
|
10
|
+
* scheduler + state machine first and wire the LLM review later when
|
|
11
|
+
* the auxiliary-credential pattern lands.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle:
|
|
14
|
+
* active → stale (no activity for STALE_AFTER_DAYS, default 30)
|
|
15
|
+
* stale → archived (no activity for ARCHIVE_AFTER_DAYS, default 90)
|
|
16
|
+
* stale → active (activity recorded after marked stale = reactivation)
|
|
17
|
+
* pinned → bypasses all auto-transitions
|
|
18
|
+
*
|
|
19
|
+
* Strict invariants:
|
|
20
|
+
* - Only touches AGENT-CREATED skills (those living under
|
|
21
|
+
* ~/.wotann/skills/agent/ — the directory `skill-forge.ts`
|
|
22
|
+
* promotes into). The curated/scientific registries are static
|
|
23
|
+
* data and out of scope.
|
|
24
|
+
* - Never auto-deletes. Archive moves the directory to
|
|
25
|
+
* ~/.wotann/skills/.archive/<name>/ which is recoverable by hand.
|
|
26
|
+
* - State writes are atomic (writeFileAtomic) so a crashed run
|
|
27
|
+
* never leaves a half-written .curator_state.
|
|
28
|
+
*
|
|
29
|
+
* Persistence layout (under WOTANN_HOME):
|
|
30
|
+
* skills/.curator_state — global curator scheduler state
|
|
31
|
+
* skills/agent/<name>/ — promoted agent-created skill
|
|
32
|
+
* skills/agent/<name>/.activity.json — per-skill activity record
|
|
33
|
+
* skills/.archive/<name>/ — archived (recoverable) skills
|
|
34
|
+
*
|
|
35
|
+
* Quality bars:
|
|
36
|
+
* QB#6 honest failures: corrupt state files return defaults rather
|
|
37
|
+
* than crashing the curator; ENOENT on a missing skill dir is
|
|
38
|
+
* the expected steady-state, not an error.
|
|
39
|
+
* QB#7 per-call state: every function reads from disk on each call
|
|
40
|
+
* and writes back atomically. No module-global caches.
|
|
41
|
+
* QB#11 sibling-site scan: `applyAutomaticTransitions` is the ONE
|
|
42
|
+
* place state mutations live — CLI status/run/pin paths all
|
|
43
|
+
* funnel through it so the bug class "two writers race on the
|
|
44
|
+
* same skill" cannot regress.
|
|
45
|
+
*/
|
|
46
|
+
/** 7 days — Hermes default. Override via {@link CuratorConfig.intervalHours}. */
|
|
47
|
+
export declare const DEFAULT_INTERVAL_HOURS: number;
|
|
48
|
+
/** 2 hours of agent inactivity before the curator considers running. */
|
|
49
|
+
export declare const DEFAULT_MIN_IDLE_HOURS = 2;
|
|
50
|
+
/** Skill marked `stale` after this many days without activity. */
|
|
51
|
+
export declare const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
52
|
+
/** Skill `archived` after this many days without activity. */
|
|
53
|
+
export declare const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
|
|
54
|
+
export type SkillState = "active" | "stale" | "archived";
|
|
55
|
+
/** Global curator scheduler state — persisted at `.curator_state`. */
|
|
56
|
+
export interface CuratorState {
|
|
57
|
+
readonly lastRunAt: string | null;
|
|
58
|
+
readonly lastRunDurationSeconds: number | null;
|
|
59
|
+
readonly lastRunSummary: string | null;
|
|
60
|
+
readonly paused: boolean;
|
|
61
|
+
readonly runCount: number;
|
|
62
|
+
}
|
|
63
|
+
/** Per-skill activity record — persisted at `agent/<name>/.activity.json`. */
|
|
64
|
+
export interface SkillActivity {
|
|
65
|
+
readonly name: string;
|
|
66
|
+
readonly state: SkillState;
|
|
67
|
+
readonly pinned: boolean;
|
|
68
|
+
readonly lastActivityAt: string | null;
|
|
69
|
+
readonly createdAt: string;
|
|
70
|
+
readonly useCount: number;
|
|
71
|
+
readonly viewCount: number;
|
|
72
|
+
}
|
|
73
|
+
/** Aggregate counts emitted by {@link applyAutomaticTransitions}. */
|
|
74
|
+
export interface TransitionCounts {
|
|
75
|
+
readonly checked: number;
|
|
76
|
+
readonly markedStale: number;
|
|
77
|
+
readonly archived: number;
|
|
78
|
+
readonly reactivated: number;
|
|
79
|
+
}
|
|
80
|
+
export interface CuratorConfig {
|
|
81
|
+
readonly intervalHours?: number;
|
|
82
|
+
readonly staleAfterDays?: number;
|
|
83
|
+
readonly archiveAfterDays?: number;
|
|
84
|
+
}
|
|
85
|
+
export interface RunResult {
|
|
86
|
+
readonly counts: TransitionCounts;
|
|
87
|
+
readonly durationSeconds: number;
|
|
88
|
+
readonly summary: string;
|
|
89
|
+
readonly dryRun: boolean;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Load the global curator scheduler state. Returns sensible defaults
|
|
93
|
+
* on missing or corrupt files — never throws.
|
|
94
|
+
*/
|
|
95
|
+
export declare function loadState(): CuratorState;
|
|
96
|
+
/** Write the global curator state atomically. Creates the parent dir if needed. */
|
|
97
|
+
export declare function saveState(state: CuratorState): void;
|
|
98
|
+
export declare function setPaused(paused: boolean): void;
|
|
99
|
+
export declare function isPaused(): boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Record a use (the user actually invoked the skill in an agent turn).
|
|
102
|
+
* Bumps {@link SkillActivity.useCount}, updates `lastActivityAt`, and
|
|
103
|
+
* reactivates archived skills (the skill clearly isn't dead).
|
|
104
|
+
*/
|
|
105
|
+
export declare function recordSkillUse(skillName: string, now?: Date): void;
|
|
106
|
+
/**
|
|
107
|
+
* Record a view (the user inspected the skill via `/skills view` or
|
|
108
|
+
* the palette). Views ALSO count as activity per the Hermes design —
|
|
109
|
+
* a recently-inspected skill shouldn't immediately archive.
|
|
110
|
+
*/
|
|
111
|
+
export declare function recordSkillView(skillName: string, now?: Date): void;
|
|
112
|
+
/**
|
|
113
|
+
* Pin or unpin a skill. Pinned skills bypass all auto-transitions.
|
|
114
|
+
* Returns false when the skill has no on-disk presence (we don't
|
|
115
|
+
* silently create a phantom activity record).
|
|
116
|
+
*/
|
|
117
|
+
export declare function setPinned(skillName: string, pinned: boolean, now?: Date): boolean;
|
|
118
|
+
/**
|
|
119
|
+
* Walk `~/.wotann/skills/agent/` and return one activity record per
|
|
120
|
+
* agent-created skill. Skills with no activity file yet are seeded
|
|
121
|
+
* with defaults derived from their directory mtime so the first
|
|
122
|
+
* curator pass has somewhere to anchor lifecycle math.
|
|
123
|
+
*/
|
|
124
|
+
export declare function listAgentSkills(): SkillActivity[];
|
|
125
|
+
/**
|
|
126
|
+
* Walk every agent-created skill and move active/stale/archived based
|
|
127
|
+
* on the latest activity timestamp. Pinned skills are never touched.
|
|
128
|
+
* Archived skills move on disk to `.archive/<name>/`. Returns aggregate
|
|
129
|
+
* counts so the CLI can render a summary.
|
|
130
|
+
*/
|
|
131
|
+
export declare function applyAutomaticTransitions(now?: Date, config?: CuratorConfig): TransitionCounts;
|
|
132
|
+
/**
|
|
133
|
+
* Returns true when the curator should run NOW. Gates:
|
|
134
|
+
* - not paused
|
|
135
|
+
* - last_run_at present AND older than intervalHours
|
|
136
|
+
*
|
|
137
|
+
* First-run behavior: a missing `last_run_at` SEEDS the timestamp and
|
|
138
|
+
* returns false (defer the first real pass by one full interval —
|
|
139
|
+
* matches Hermes' "deferred first run" pattern so the very first
|
|
140
|
+
* background tick after `wotann update` doesn't immediately mutate
|
|
141
|
+
* the library).
|
|
142
|
+
*
|
|
143
|
+
* The min-idle-hours gate is intentionally NOT applied here — callers
|
|
144
|
+
* (kairos idle detector, CLI manual `run`) know their own idle state
|
|
145
|
+
* and apply it at the call site.
|
|
146
|
+
*/
|
|
147
|
+
export declare function shouldRunNow(now?: Date, intervalHours?: number): boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Execute one curator pass. When `dryRun` is true, computes what would
|
|
150
|
+
* change but does NOT mutate the skill library or the curator state.
|
|
151
|
+
* Otherwise applies transitions and persists `lastRunAt`/`runCount`/
|
|
152
|
+
* `lastRunSummary` for the next status render.
|
|
153
|
+
*/
|
|
154
|
+
export declare function runCurator(now?: Date, config?: CuratorConfig & {
|
|
155
|
+
dryRun?: boolean;
|
|
156
|
+
}): RunResult;
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill Curator — inactivity-triggered skill lifecycle manager.
|
|
3
|
+
*
|
|
4
|
+
* Port of Hermes' `agent/curator.py` (NousResearch/hermes-agent, MIT).
|
|
5
|
+
* The Hermes curator runs background reviews of agent-created skills,
|
|
6
|
+
* auto-transitioning them between lifecycle states (active → stale →
|
|
7
|
+
* archived) based on real activity timestamps, and pinning bypasses
|
|
8
|
+
* the auto-transitions entirely. Hermes' full curator also spawns a
|
|
9
|
+
* forked AIAgent to do LLM-graded review; we ship the pure-function
|
|
10
|
+
* scheduler + state machine first and wire the LLM review later when
|
|
11
|
+
* the auxiliary-credential pattern lands.
|
|
12
|
+
*
|
|
13
|
+
* Lifecycle:
|
|
14
|
+
* active → stale (no activity for STALE_AFTER_DAYS, default 30)
|
|
15
|
+
* stale → archived (no activity for ARCHIVE_AFTER_DAYS, default 90)
|
|
16
|
+
* stale → active (activity recorded after marked stale = reactivation)
|
|
17
|
+
* pinned → bypasses all auto-transitions
|
|
18
|
+
*
|
|
19
|
+
* Strict invariants:
|
|
20
|
+
* - Only touches AGENT-CREATED skills (those living under
|
|
21
|
+
* ~/.wotann/skills/agent/ — the directory `skill-forge.ts`
|
|
22
|
+
* promotes into). The curated/scientific registries are static
|
|
23
|
+
* data and out of scope.
|
|
24
|
+
* - Never auto-deletes. Archive moves the directory to
|
|
25
|
+
* ~/.wotann/skills/.archive/<name>/ which is recoverable by hand.
|
|
26
|
+
* - State writes are atomic (writeFileAtomic) so a crashed run
|
|
27
|
+
* never leaves a half-written .curator_state.
|
|
28
|
+
*
|
|
29
|
+
* Persistence layout (under WOTANN_HOME):
|
|
30
|
+
* skills/.curator_state — global curator scheduler state
|
|
31
|
+
* skills/agent/<name>/ — promoted agent-created skill
|
|
32
|
+
* skills/agent/<name>/.activity.json — per-skill activity record
|
|
33
|
+
* skills/.archive/<name>/ — archived (recoverable) skills
|
|
34
|
+
*
|
|
35
|
+
* Quality bars:
|
|
36
|
+
* QB#6 honest failures: corrupt state files return defaults rather
|
|
37
|
+
* than crashing the curator; ENOENT on a missing skill dir is
|
|
38
|
+
* the expected steady-state, not an error.
|
|
39
|
+
* QB#7 per-call state: every function reads from disk on each call
|
|
40
|
+
* and writes back atomically. No module-global caches.
|
|
41
|
+
* QB#11 sibling-site scan: `applyAutomaticTransitions` is the ONE
|
|
42
|
+
* place state mutations live — CLI status/run/pin paths all
|
|
43
|
+
* funnel through it so the bug class "two writers race on the
|
|
44
|
+
* same skill" cannot regress.
|
|
45
|
+
*/
|
|
46
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync } from "node:fs";
|
|
47
|
+
import { join } from "node:path";
|
|
48
|
+
import { writeFileAtomic } from "../utils/atomic-io.js";
|
|
49
|
+
import { resolveWotannHomeSubdir } from "../utils/wotann-home.js";
|
|
50
|
+
// ── Constants (Hermes defaults) ──────────────────────────────────
|
|
51
|
+
/** 7 days — Hermes default. Override via {@link CuratorConfig.intervalHours}. */
|
|
52
|
+
export const DEFAULT_INTERVAL_HOURS = 24 * 7;
|
|
53
|
+
/** 2 hours of agent inactivity before the curator considers running. */
|
|
54
|
+
export const DEFAULT_MIN_IDLE_HOURS = 2;
|
|
55
|
+
/** Skill marked `stale` after this many days without activity. */
|
|
56
|
+
export const DEFAULT_STALE_AFTER_DAYS = 30;
|
|
57
|
+
/** Skill `archived` after this many days without activity. */
|
|
58
|
+
export const DEFAULT_ARCHIVE_AFTER_DAYS = 90;
|
|
59
|
+
// ── Path helpers ──────────────────────────────────────────────────
|
|
60
|
+
function stateFilePath() {
|
|
61
|
+
return resolveWotannHomeSubdir("skills", ".curator_state");
|
|
62
|
+
}
|
|
63
|
+
function agentSkillsDir() {
|
|
64
|
+
return resolveWotannHomeSubdir("skills", "agent");
|
|
65
|
+
}
|
|
66
|
+
function archiveDir() {
|
|
67
|
+
return resolveWotannHomeSubdir("skills", ".archive");
|
|
68
|
+
}
|
|
69
|
+
function activityFilePath(skillName) {
|
|
70
|
+
return resolveWotannHomeSubdir("skills", "agent", skillName, ".activity.json");
|
|
71
|
+
}
|
|
72
|
+
// ── Global state I/O ──────────────────────────────────────────────
|
|
73
|
+
function defaultState() {
|
|
74
|
+
return {
|
|
75
|
+
lastRunAt: null,
|
|
76
|
+
lastRunDurationSeconds: null,
|
|
77
|
+
lastRunSummary: null,
|
|
78
|
+
paused: false,
|
|
79
|
+
runCount: 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Load the global curator scheduler state. Returns sensible defaults
|
|
84
|
+
* on missing or corrupt files — never throws.
|
|
85
|
+
*/
|
|
86
|
+
export function loadState() {
|
|
87
|
+
const path = stateFilePath();
|
|
88
|
+
if (!existsSync(path))
|
|
89
|
+
return defaultState();
|
|
90
|
+
try {
|
|
91
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
92
|
+
if (parsed !== null && typeof parsed === "object") {
|
|
93
|
+
return { ...defaultState(), ...parsed };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Corrupt JSON — return defaults rather than crash the run.
|
|
98
|
+
}
|
|
99
|
+
return defaultState();
|
|
100
|
+
}
|
|
101
|
+
/** Write the global curator state atomically. Creates the parent dir if needed. */
|
|
102
|
+
export function saveState(state) {
|
|
103
|
+
writeFileAtomic(stateFilePath(), JSON.stringify(state, null, 2) + "\n");
|
|
104
|
+
}
|
|
105
|
+
export function setPaused(paused) {
|
|
106
|
+
saveState({ ...loadState(), paused });
|
|
107
|
+
}
|
|
108
|
+
export function isPaused() {
|
|
109
|
+
return loadState().paused;
|
|
110
|
+
}
|
|
111
|
+
// ── Per-skill activity ────────────────────────────────────────────
|
|
112
|
+
function loadActivity(skillName) {
|
|
113
|
+
const path = activityFilePath(skillName);
|
|
114
|
+
if (!existsSync(path))
|
|
115
|
+
return null;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
118
|
+
if (parsed !== null && typeof parsed === "object" && typeof parsed.name === "string") {
|
|
119
|
+
return {
|
|
120
|
+
name: parsed.name,
|
|
121
|
+
state: parsed.state ?? "active",
|
|
122
|
+
pinned: parsed.pinned === true,
|
|
123
|
+
lastActivityAt: typeof parsed.lastActivityAt === "string" ? parsed.lastActivityAt : null,
|
|
124
|
+
createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(),
|
|
125
|
+
useCount: typeof parsed.useCount === "number" ? parsed.useCount : 0,
|
|
126
|
+
viewCount: typeof parsed.viewCount === "number" ? parsed.viewCount : 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Corrupt activity file — treat as missing.
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
function saveActivity(activity) {
|
|
136
|
+
writeFileAtomic(activityFilePath(activity.name), JSON.stringify(activity, null, 2) + "\n");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Record a use (the user actually invoked the skill in an agent turn).
|
|
140
|
+
* Bumps {@link SkillActivity.useCount}, updates `lastActivityAt`, and
|
|
141
|
+
* reactivates archived skills (the skill clearly isn't dead).
|
|
142
|
+
*/
|
|
143
|
+
export function recordSkillUse(skillName, now = new Date()) {
|
|
144
|
+
const existing = loadActivity(skillName);
|
|
145
|
+
saveActivity({
|
|
146
|
+
name: skillName,
|
|
147
|
+
state: existing?.state === "archived" ? "active" : (existing?.state ?? "active"),
|
|
148
|
+
pinned: existing?.pinned ?? false,
|
|
149
|
+
lastActivityAt: now.toISOString(),
|
|
150
|
+
createdAt: existing?.createdAt ?? now.toISOString(),
|
|
151
|
+
useCount: (existing?.useCount ?? 0) + 1,
|
|
152
|
+
viewCount: existing?.viewCount ?? 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Record a view (the user inspected the skill via `/skills view` or
|
|
157
|
+
* the palette). Views ALSO count as activity per the Hermes design —
|
|
158
|
+
* a recently-inspected skill shouldn't immediately archive.
|
|
159
|
+
*/
|
|
160
|
+
export function recordSkillView(skillName, now = new Date()) {
|
|
161
|
+
const existing = loadActivity(skillName);
|
|
162
|
+
saveActivity({
|
|
163
|
+
name: skillName,
|
|
164
|
+
state: existing?.state ?? "active",
|
|
165
|
+
pinned: existing?.pinned ?? false,
|
|
166
|
+
lastActivityAt: now.toISOString(),
|
|
167
|
+
createdAt: existing?.createdAt ?? now.toISOString(),
|
|
168
|
+
useCount: existing?.useCount ?? 0,
|
|
169
|
+
viewCount: (existing?.viewCount ?? 0) + 1,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Pin or unpin a skill. Pinned skills bypass all auto-transitions.
|
|
174
|
+
* Returns false when the skill has no on-disk presence (we don't
|
|
175
|
+
* silently create a phantom activity record).
|
|
176
|
+
*/
|
|
177
|
+
export function setPinned(skillName, pinned, now = new Date()) {
|
|
178
|
+
const existing = loadActivity(skillName);
|
|
179
|
+
if (existing !== null) {
|
|
180
|
+
saveActivity({ ...existing, pinned });
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
const skillDir = join(agentSkillsDir(), skillName);
|
|
184
|
+
if (!existsSync(skillDir))
|
|
185
|
+
return false;
|
|
186
|
+
saveActivity({
|
|
187
|
+
name: skillName,
|
|
188
|
+
state: "active",
|
|
189
|
+
pinned,
|
|
190
|
+
lastActivityAt: null,
|
|
191
|
+
createdAt: now.toISOString(),
|
|
192
|
+
useCount: 0,
|
|
193
|
+
viewCount: 0,
|
|
194
|
+
});
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
// ── Discovery ─────────────────────────────────────────────────────
|
|
198
|
+
/**
|
|
199
|
+
* Walk `~/.wotann/skills/agent/` and return one activity record per
|
|
200
|
+
* agent-created skill. Skills with no activity file yet are seeded
|
|
201
|
+
* with defaults derived from their directory mtime so the first
|
|
202
|
+
* curator pass has somewhere to anchor lifecycle math.
|
|
203
|
+
*/
|
|
204
|
+
export function listAgentSkills() {
|
|
205
|
+
const dir = agentSkillsDir();
|
|
206
|
+
if (!existsSync(dir))
|
|
207
|
+
return [];
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
210
|
+
if (!entry.isDirectory())
|
|
211
|
+
continue;
|
|
212
|
+
// Skip dotfile dirs (.archive, .scratch, etc.).
|
|
213
|
+
if (entry.name.startsWith("."))
|
|
214
|
+
continue;
|
|
215
|
+
const activity = loadActivity(entry.name);
|
|
216
|
+
if (activity !== null) {
|
|
217
|
+
out.push(activity);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
// Skill exists on disk but no activity record yet — seed one.
|
|
221
|
+
let createdAt;
|
|
222
|
+
try {
|
|
223
|
+
createdAt = statSync(join(dir, entry.name)).birthtime.toISOString();
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
createdAt = new Date().toISOString();
|
|
227
|
+
}
|
|
228
|
+
const seeded = {
|
|
229
|
+
name: entry.name,
|
|
230
|
+
state: "active",
|
|
231
|
+
pinned: false,
|
|
232
|
+
lastActivityAt: null,
|
|
233
|
+
createdAt,
|
|
234
|
+
useCount: 0,
|
|
235
|
+
viewCount: 0,
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
saveActivity(seeded);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// Best-effort seed — if write fails (read-only fs in CI), still
|
|
242
|
+
// surface the skill in the report.
|
|
243
|
+
}
|
|
244
|
+
out.push(seeded);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
// ── Auto-transitions (pure function over time) ────────────────────
|
|
249
|
+
/**
|
|
250
|
+
* Walk every agent-created skill and move active/stale/archived based
|
|
251
|
+
* on the latest activity timestamp. Pinned skills are never touched.
|
|
252
|
+
* Archived skills move on disk to `.archive/<name>/`. Returns aggregate
|
|
253
|
+
* counts so the CLI can render a summary.
|
|
254
|
+
*/
|
|
255
|
+
export function applyAutomaticTransitions(now = new Date(), config = {}) {
|
|
256
|
+
const staleAfter = config.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
|
|
257
|
+
const archiveAfter = config.archiveAfterDays ?? DEFAULT_ARCHIVE_AFTER_DAYS;
|
|
258
|
+
const nowMs = now.getTime();
|
|
259
|
+
const staleCutoff = nowMs - staleAfter * 86_400_000;
|
|
260
|
+
const archiveCutoff = nowMs - archiveAfter * 86_400_000;
|
|
261
|
+
let checked = 0;
|
|
262
|
+
let markedStale = 0;
|
|
263
|
+
let archived = 0;
|
|
264
|
+
let reactivated = 0;
|
|
265
|
+
for (const skill of listAgentSkills()) {
|
|
266
|
+
checked++;
|
|
267
|
+
if (skill.pinned)
|
|
268
|
+
continue;
|
|
269
|
+
const anchorIso = skill.lastActivityAt ?? skill.createdAt;
|
|
270
|
+
const anchor = new Date(anchorIso).getTime();
|
|
271
|
+
if (!Number.isFinite(anchor))
|
|
272
|
+
continue;
|
|
273
|
+
if (anchor <= archiveCutoff && skill.state !== "archived") {
|
|
274
|
+
if (archiveSkillOnDisk(skill.name)) {
|
|
275
|
+
archived++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else if (anchor <= staleCutoff && skill.state === "active") {
|
|
279
|
+
saveActivity({ ...skill, state: "stale" });
|
|
280
|
+
markedStale++;
|
|
281
|
+
}
|
|
282
|
+
else if (anchor > staleCutoff && skill.state === "stale") {
|
|
283
|
+
saveActivity({ ...skill, state: "active" });
|
|
284
|
+
reactivated++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return { checked, markedStale, archived, reactivated };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Move `skills/agent/<name>/` to `skills/.archive/<name>/`. If the
|
|
291
|
+
* archive slot is already occupied (re-archive after manual restore),
|
|
292
|
+
* suffix with the current timestamp so nothing is overwritten.
|
|
293
|
+
* Returns false when the source doesn't exist (skill already gone).
|
|
294
|
+
*/
|
|
295
|
+
function archiveSkillOnDisk(skillName) {
|
|
296
|
+
const src = join(agentSkillsDir(), skillName);
|
|
297
|
+
if (!existsSync(src))
|
|
298
|
+
return false;
|
|
299
|
+
mkdirSync(archiveDir(), { recursive: true });
|
|
300
|
+
let dst = join(archiveDir(), skillName);
|
|
301
|
+
if (existsSync(dst)) {
|
|
302
|
+
dst = `${dst}.${Date.now()}`;
|
|
303
|
+
}
|
|
304
|
+
renameSync(src, dst);
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
// ── Scheduler ─────────────────────────────────────────────────────
|
|
308
|
+
/**
|
|
309
|
+
* Returns true when the curator should run NOW. Gates:
|
|
310
|
+
* - not paused
|
|
311
|
+
* - last_run_at present AND older than intervalHours
|
|
312
|
+
*
|
|
313
|
+
* First-run behavior: a missing `last_run_at` SEEDS the timestamp and
|
|
314
|
+
* returns false (defer the first real pass by one full interval —
|
|
315
|
+
* matches Hermes' "deferred first run" pattern so the very first
|
|
316
|
+
* background tick after `wotann update` doesn't immediately mutate
|
|
317
|
+
* the library).
|
|
318
|
+
*
|
|
319
|
+
* The min-idle-hours gate is intentionally NOT applied here — callers
|
|
320
|
+
* (kairos idle detector, CLI manual `run`) know their own idle state
|
|
321
|
+
* and apply it at the call site.
|
|
322
|
+
*/
|
|
323
|
+
export function shouldRunNow(now = new Date(), intervalHours = DEFAULT_INTERVAL_HOURS) {
|
|
324
|
+
const state = loadState();
|
|
325
|
+
if (state.paused)
|
|
326
|
+
return false;
|
|
327
|
+
if (state.lastRunAt === null) {
|
|
328
|
+
// Seed without running. Defer first pass by one interval.
|
|
329
|
+
saveState({ ...state, lastRunAt: now.toISOString() });
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const last = new Date(state.lastRunAt).getTime();
|
|
333
|
+
if (!Number.isFinite(last))
|
|
334
|
+
return true; // corrupt timestamp → run
|
|
335
|
+
const intervalMs = intervalHours * 3_600_000;
|
|
336
|
+
return now.getTime() - last >= intervalMs;
|
|
337
|
+
}
|
|
338
|
+
// ── Run ───────────────────────────────────────────────────────────
|
|
339
|
+
/**
|
|
340
|
+
* Execute one curator pass. When `dryRun` is true, computes what would
|
|
341
|
+
* change but does NOT mutate the skill library or the curator state.
|
|
342
|
+
* Otherwise applies transitions and persists `lastRunAt`/`runCount`/
|
|
343
|
+
* `lastRunSummary` for the next status render.
|
|
344
|
+
*/
|
|
345
|
+
export function runCurator(now = new Date(), config = {}) {
|
|
346
|
+
const start = Date.now();
|
|
347
|
+
const dryRun = config.dryRun === true;
|
|
348
|
+
const counts = dryRun
|
|
349
|
+
? computeWouldTransition(now, config)
|
|
350
|
+
: applyAutomaticTransitions(now, config);
|
|
351
|
+
const end = Date.now();
|
|
352
|
+
const summary = formatSummary(counts, dryRun);
|
|
353
|
+
if (!dryRun) {
|
|
354
|
+
const prev = loadState();
|
|
355
|
+
saveState({
|
|
356
|
+
...prev,
|
|
357
|
+
lastRunAt: now.toISOString(),
|
|
358
|
+
lastRunDurationSeconds: Math.max(0, Math.round((end - start) / 1000)),
|
|
359
|
+
lastRunSummary: summary,
|
|
360
|
+
runCount: prev.runCount + 1,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return {
|
|
364
|
+
counts,
|
|
365
|
+
durationSeconds: (end - start) / 1000,
|
|
366
|
+
summary,
|
|
367
|
+
dryRun,
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function computeWouldTransition(now, config) {
|
|
371
|
+
const staleAfter = config.staleAfterDays ?? DEFAULT_STALE_AFTER_DAYS;
|
|
372
|
+
const archiveAfter = config.archiveAfterDays ?? DEFAULT_ARCHIVE_AFTER_DAYS;
|
|
373
|
+
const nowMs = now.getTime();
|
|
374
|
+
const staleCutoff = nowMs - staleAfter * 86_400_000;
|
|
375
|
+
const archiveCutoff = nowMs - archiveAfter * 86_400_000;
|
|
376
|
+
let checked = 0;
|
|
377
|
+
let markedStale = 0;
|
|
378
|
+
let archived = 0;
|
|
379
|
+
let reactivated = 0;
|
|
380
|
+
for (const skill of listAgentSkills()) {
|
|
381
|
+
checked++;
|
|
382
|
+
if (skill.pinned)
|
|
383
|
+
continue;
|
|
384
|
+
const anchor = new Date(skill.lastActivityAt ?? skill.createdAt).getTime();
|
|
385
|
+
if (!Number.isFinite(anchor))
|
|
386
|
+
continue;
|
|
387
|
+
if (anchor <= archiveCutoff && skill.state !== "archived")
|
|
388
|
+
archived++;
|
|
389
|
+
else if (anchor <= staleCutoff && skill.state === "active")
|
|
390
|
+
markedStale++;
|
|
391
|
+
else if (anchor > staleCutoff && skill.state === "stale")
|
|
392
|
+
reactivated++;
|
|
393
|
+
}
|
|
394
|
+
return { checked, markedStale, archived, reactivated };
|
|
395
|
+
}
|
|
396
|
+
function formatSummary(counts, dryRun) {
|
|
397
|
+
const prefix = dryRun ? "[dry-run] " : "";
|
|
398
|
+
return (`${prefix}checked ${counts.checked}, ` +
|
|
399
|
+
`marked stale ${counts.markedStale}, ` +
|
|
400
|
+
`archived ${counts.archived}, ` +
|
|
401
|
+
`reactivated ${counts.reactivated}`);
|
|
402
|
+
}
|