@joenandez/academy 0.4.0 → 0.5.12

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.
Files changed (121) hide show
  1. package/.claude-plugin/marketplace.json +5 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +390 -0
  4. package/README.md +67 -122
  5. package/hooks/backstory/.claude-plugin/plugin.json +11 -0
  6. package/hooks/backstory/SOURCE +2 -0
  7. package/hooks/backstory/bin/backstory +9 -0
  8. package/hooks/backstory/hooks/hooks.json +45 -0
  9. package/hooks/backstory/scripts/knowledge.mjs +87 -0
  10. package/hooks/backstory/scripts/memory.mjs +685 -0
  11. package/hooks/backstory/scripts/tool-tick.mjs +1 -0
  12. package/hooks/backstory/skills/capture/SKILL.md +71 -0
  13. package/hooks/backstory/skills/capture/references/knowledge-capture-input.json +16 -0
  14. package/hooks/backstory/skills/capture/references/tagging-policy.md +29 -0
  15. package/hooks/backstory/skills/memory/SKILL.md +70 -0
  16. package/hooks/backstory/skills/setup/SKILL.md +85 -0
  17. package/hooks/context_metrics.mjs +67 -0
  18. package/hooks/hook_runtime.mjs +43 -2
  19. package/hooks/memory_bridge.mjs +286 -52
  20. package/hooks/memory_store.mjs +78 -26
  21. package/hooks/register_session.mjs +76 -14
  22. package/hooks/roque/.claude-plugin/plugin.json +6 -0
  23. package/hooks/roque/LICENSE +21 -0
  24. package/hooks/roque/SOURCE +1 -0
  25. package/hooks/roque/hooks.json +38 -0
  26. package/hooks/roque/package.json +3 -0
  27. package/hooks/roque/roque.js +1690 -0
  28. package/hooks/sync_memory.mjs +11 -7
  29. package/package.json +7 -3
  30. package/scripts/cli/agent-gate.mjs +70 -0
  31. package/scripts/cli/agent-id.mjs +37 -0
  32. package/scripts/cli/archive.mjs +39 -38
  33. package/scripts/cli/archived.mjs +4 -2
  34. package/scripts/cli/args-kinds.mjs +33 -0
  35. package/scripts/cli/args-notes.mjs +42 -0
  36. package/scripts/cli/args-owner.mjs +37 -0
  37. package/scripts/cli/args-run.mjs +38 -0
  38. package/scripts/cli/args.mjs +47 -104
  39. package/scripts/cli/beads.mjs +57 -0
  40. package/scripts/cli/codex.mjs +66 -12
  41. package/scripts/cli/core.mjs +117 -41
  42. package/scripts/cli/create.mjs +224 -76
  43. package/scripts/cli/curator-feed-codex.mjs +81 -0
  44. package/scripts/cli/curator-feed-observations.mjs +97 -0
  45. package/scripts/cli/curator-feed-scope.mjs +124 -0
  46. package/scripts/cli/curator-feed-sources.mjs +172 -0
  47. package/scripts/cli/curator-feed.mjs +318 -0
  48. package/scripts/cli/default-name.mjs +73 -0
  49. package/scripts/cli/doctor.mjs +67 -2
  50. package/scripts/cli/eventlog.mjs +6 -2
  51. package/scripts/cli/exit-code.mjs +9 -0
  52. package/scripts/cli/first-run.mjs +115 -0
  53. package/scripts/cli/heartbeat.mjs +135 -0
  54. package/scripts/cli/helm-skip.mjs +20 -0
  55. package/scripts/cli/hire-headless.mjs +37 -22
  56. package/scripts/cli/hire.mjs +8 -2
  57. package/scripts/cli/inspect.mjs +36 -18
  58. package/scripts/cli/lifecycle.mjs +95 -49
  59. package/scripts/cli/main.mjs +32 -2
  60. package/scripts/cli/memory-env.mjs +124 -0
  61. package/scripts/cli/migrate.mjs +13 -7
  62. package/scripts/cli/next-steps.mjs +13 -0
  63. package/scripts/cli/nightly-launch.mjs +118 -0
  64. package/scripts/cli/nightly.mjs +228 -0
  65. package/scripts/cli/notes.mjs +58 -8
  66. package/scripts/cli/owner-config.mjs +181 -0
  67. package/scripts/cli/owner-jobs.mjs +201 -0
  68. package/scripts/cli/prompt-append.mjs +135 -0
  69. package/scripts/cli/rename.mjs +90 -109
  70. package/scripts/cli/run-context.mjs +50 -0
  71. package/scripts/cli/run.mjs +236 -79
  72. package/scripts/cli/scaffold.mjs +119 -126
  73. package/scripts/cli/schedule-errors.mjs +70 -0
  74. package/scripts/cli/sessions.mjs +10 -3
  75. package/scripts/cli/skills-check.mjs +147 -0
  76. package/scripts/cli/skills.mjs +170 -0
  77. package/scripts/cli/start.mjs +201 -0
  78. package/scripts/cli/templates.mjs +119 -45
  79. package/scripts/cli/tldr.mjs +184 -0
  80. package/scripts/cli/toml-string.mjs +76 -0
  81. package/scripts/cli/user-work-view.mjs +48 -0
  82. package/scripts/cli/writable-dirs.mjs +23 -0
  83. package/scripts/cli/yaml.mjs +1 -1
  84. package/skills/hire/SKILL.md +112 -100
  85. package/skills/hire/references/owner.md +68 -0
  86. package/templates/agents/claude-code/knowledge-curator.md +5 -2
  87. package/templates/agents/codex/knowledge-curator.toml +2 -1
  88. package/templates/operating-model/base.md +78 -0
  89. package/templates/operating-model/owner.md +11 -0
  90. package/templates/operating-model/principal.md +9 -0
  91. package/templates/operating-model/specialist.md +9 -0
  92. package/templates/packs/owner/craft.md +32 -0
  93. package/templates/packs/owner/identity.md +13 -0
  94. package/templates/packs/owner/mandate.md +26 -0
  95. package/templates/packs/owner/owner/SKILL.md +118 -0
  96. package/templates/packs/owner/owner-feedback/SKILL.md +49 -0
  97. package/templates/packs/owner/owner-heartbeat/SKILL.md +35 -0
  98. package/templates/packs/owner/owner-nightly/SKILL.md +114 -0
  99. package/templates/packs/owner/owner-review/SKILL.md +70 -0
  100. package/templates/packs/owner/owner-work/SKILL.md +53 -0
  101. package/templates/packs/principal/craft.md +27 -0
  102. package/templates/packs/principal/identity.md +13 -0
  103. package/templates/packs/principal/mandate.md +23 -0
  104. package/templates/packs/principal/principal-brief/SKILL.md +44 -0
  105. package/templates/packs/principal/principal-intro/SKILL.md +95 -0
  106. package/templates/packs/principal/principal-nightly/SKILL.md +152 -0
  107. package/templates/skills/check-in/SKILL.md +34 -24
  108. package/templates/skills/knowledge-curation/SKILL.md +89 -48
  109. package/templates/skills/local-rules/SKILL.md +86 -0
  110. package/templates/skills/nightly-consolidation/SKILL.md +131 -100
  111. package/templates/skills/skill-method/SKILL.md +79 -0
  112. package/templates/skills/user-nightly/SKILL.md +143 -0
  113. package/templates/skills/work-graph/SKILL.md +75 -0
  114. package/conformance/README.md +0 -60
  115. package/conformance/discovery.test.mjs +0 -140
  116. package/conformance/envelope.test.mjs +0 -185
  117. package/conformance/error-codes.test.mjs +0 -125
  118. package/conformance/harness.mjs +0 -180
  119. package/conformance/identity.test.mjs +0 -125
  120. package/docs/integration-guide.md +0 -1042
  121. package/templates/skills/self-update/SKILL.md +0 -121
@@ -0,0 +1,118 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import {
3
+ ACADEMY_CLI_PATH,
4
+ SCHEDULED_CLAUDE_PERMISSION_ARGS,
5
+ SCHEDULED_CODEX_PERMISSION_ARGS,
6
+ } from './core.mjs';
7
+ import { childExitCode } from './exit-code.mjs';
8
+ import { NIGHTLY_JOB_FLAG } from './nightly.mjs';
9
+
10
+ // The nightly routine's child launch: its prompt, its permission, its env.
11
+
12
+ // The skill finds its own pending entries: those no dreams report consumed.
13
+ export function nightlyPrompt(skill) {
14
+ return [
15
+ `Use the ${skill} skill to consolidate the observation entries that no dreams report has consumed yet.`,
16
+ 'Update the v3 memory surfaces conservatively, write the dreams report,',
17
+ 'and finish with the dreams report path.',
18
+ ].join(' ');
19
+ }
20
+
21
+ // Each permission flag a scheduler may render into a provider launch, with the
22
+ // number of values it takes.
23
+ const PERMISSION_FLAGS = {
24
+ claude: { '--permission-mode': 1, '--dangerously-skip-permissions': 0 },
25
+ codex: {
26
+ '--dangerously-bypass-approvals-and-sandbox': 0,
27
+ '--ask-for-approval': 1,
28
+ '-a': 1,
29
+ '--sandbox': 1,
30
+ '-s': 1,
31
+ },
32
+ };
33
+
34
+ // The structured-output flags a scheduler renders so it can read the
35
+ // provider's own session id (Claude) or thread id (Codex) from stdout as soon
36
+ // as the provider starts; without them a one-shot run prints nothing until it
37
+ // exits, and the scheduler counts it as never started.
38
+ const OUTPUT_FLAGS = {
39
+ claude: { '--verbose': 0, '--output-format': 1 },
40
+ codex: { '--json': 0 },
41
+ };
42
+
43
+ // The flags of the runtime that will launch, as a scheduler wrote them into
44
+ // its provider argv; flags for the other runtime are not carried.
45
+ function carriedArgs(table, runtime, providerArgv) {
46
+ const flags = table[runtime === 'codex' ? 'codex' : 'claude'];
47
+ const carried = [];
48
+ providerArgv.forEach((arg, index) => {
49
+ const [flag, inline] = arg.split(/=(.*)/s);
50
+ const takes = flags[flag];
51
+ const value = inline ?? providerArgv[index + 1];
52
+ if (takes === 0) carried.push(flag);
53
+ else if (takes === 1 && value !== undefined) carried.push(flag, value);
54
+ });
55
+ return carried;
56
+ }
57
+
58
+ // A provider job's approved permission replaces the scheduled default, so the
59
+ // model that consolidates runs with what the job's owner approved for it.
60
+ function childPermissionArgs(runtime, providerArgv) {
61
+ const carried = carriedArgs(PERMISSION_FLAGS, runtime, providerArgv);
62
+ if (runtime !== 'codex') return carried.length ? carried : SCHEDULED_CLAUDE_PERMISSION_ARGS;
63
+ if (!carried.length) return SCHEDULED_CODEX_PERMISSION_ARGS;
64
+ const exec = SCHEDULED_CODEX_PERMISSION_ARGS.indexOf('exec');
65
+ return [...carried, ...SCHEDULED_CODEX_PERMISSION_ARGS.slice(exec)];
66
+ }
67
+
68
+ // Codex takes its output flag after `exec`; Claude takes it before `-p`.
69
+ function childProviderArgs(runtime, permissionArgs, outputArgs, prompt) {
70
+ if (runtime !== 'codex') return [...permissionArgs, ...outputArgs, '-p', prompt];
71
+ const afterExec = permissionArgs.indexOf('exec') + 1;
72
+ return [
73
+ ...permissionArgs.slice(0, afterExec),
74
+ ...outputArgs,
75
+ ...permissionArgs.slice(afterExec),
76
+ prompt,
77
+ ];
78
+ }
79
+
80
+ export function launchNightlyChild(dir, name, launch) {
81
+ const { runtime, skill, providerArgv = [] } = launch;
82
+ const prompt = nightlyPrompt(skill);
83
+ const permissionArgs = childPermissionArgs(runtime, providerArgv);
84
+ const outputArgs = carriedArgs(OUTPUT_FLAGS, runtime, providerArgv);
85
+ console.log(
86
+ JSON.stringify({
87
+ event: 'nightly.launch',
88
+ params: { agent: name, runtime, skill, permissionArgs, outputArgs },
89
+ }),
90
+ );
91
+ const providerArgs = childProviderArgs(runtime, permissionArgs, outputArgs, prompt);
92
+ // The child, and the provider it launches, write straight to this process's
93
+ // stdout descriptor, so each provider line reaches the scheduler the moment
94
+ // it is written. This process writes nothing while the child runs, so its
95
+ // own JSON lines never interleave with the provider's.
96
+ const result = spawnSync(
97
+ process.execPath,
98
+ [ACADEMY_CLI_PATH, 'run', name, '--', ...providerArgs],
99
+ {
100
+ stdio: 'inherit',
101
+ cwd: dir,
102
+ env: nightlyChildEnv(),
103
+ },
104
+ );
105
+ return childExitCode(result);
106
+ }
107
+
108
+ // The dispatch flag is cleared so the child launches its provider instead of
109
+ // entering the nightly routine again. The scheduler's job id still reaches the
110
+ // child; ACADEMY_NIGHTLY_RUN keeps the run entry from deriving the flag from it.
111
+ export function nightlyChildEnv() {
112
+ const { [NIGHTLY_JOB_FLAG]: _dispatched, ...inherited } = process.env;
113
+ return {
114
+ ...inherited,
115
+ ACADEMY_SKIP_NIGHTLY_TASK: '1',
116
+ ACADEMY_NIGHTLY_RUN: '1',
117
+ };
118
+ }
@@ -0,0 +1,228 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ statSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
+ import { estimateTokens } from '../../hooks/context_metrics.mjs';
13
+ import { sweepBridgeObservations } from '../../hooks/memory_bridge.mjs';
14
+ import { markPendingConsolidation, withPendingMarkerLock } from '../../hooks/memory_store.mjs';
15
+ import { readJsonFile } from './codex.mjs';
16
+ import { printHelmSkip } from './helm-skip.mjs';
17
+ import { KIND_PROFILES, kindOf, surfacesFor } from './core.mjs';
18
+ import { skillTemplateExists } from './skills.mjs';
19
+
20
+ // The nightly skill owns consolidation: it finds the archive entries recorded
21
+ // after its newest dreams report, consolidates them, and writes the next
22
+ // report. Academy launches it when the pending marker says there was session
23
+ // activity, and records only how the run ended. It never reads the report.
24
+
25
+ // The section names the skills render into the dreams report. The report's
26
+ // consumed entries are the skill's own checkpoint for the next nightly.
27
+ export const NIGHTLY_REPORT_EVIDENCE = {
28
+ section: 'Consolidation Evidence',
29
+ consumed: 'Consumed entries',
30
+ changed: 'Changed surfaces',
31
+ noChange: 'No-change reason',
32
+ };
33
+
34
+ /** Derived from an Owner nightly job's id so `academy run` enters the nightly routine. */
35
+ export const NIGHTLY_JOB_FLAG = 'ACADEMY_NIGHTLY_JOB';
36
+
37
+ // A pack nightly skill replaces the universal one for its kind.
38
+ export const NIGHTLY_SKILL_BY_KIND = {
39
+ specialist: 'nightly-consolidation',
40
+ principal: 'principal-nightly',
41
+ owner: 'owner-nightly',
42
+ };
43
+
44
+ // The child's own run renders exactly the profile's skills right before the
45
+ // provider starts, so the skill is present only when the profile lists it and
46
+ // a template for it exists: a missing template would fail mid-render instead.
47
+ export function nightlySkillFor(dir, templatesRoot) {
48
+ const kind = kindOf(dir);
49
+ const skill = NIGHTLY_SKILL_BY_KIND[kind];
50
+ const present =
51
+ KIND_PROFILES[kind].skills.includes(skill) && skillTemplateExists(kind, skill, templatesRoot);
52
+ return { kind, skill, present };
53
+ }
54
+
55
+ /** The scheduled prompt of a provider nightly job; the routine builds its own. */
56
+ export function nightlyRoute(kind) {
57
+ return `Nightly consolidation: use the ${NIGHTLY_SKILL_BY_KIND[kind]} skill.`;
58
+ }
59
+
60
+ const NIGHTLY_SWEEP_DAYS = 7;
61
+
62
+ const recordPath = (dir) => join(dir, 'memory', 'nightly-last.json');
63
+
64
+ // A one-shot scheduled run's last turn lands after its only Stop, so the
65
+ // nightly sweeps a week back before it reads the marker. Swept input is owed
66
+ // consolidation, so it commits with its marker bump, as every writer's does.
67
+ export function sweepBeforeNightly(dir, name) {
68
+ let copied = 0;
69
+ mkdirSync(join(dir, 'memory'), { recursive: true });
70
+ markPendingConsolidation(join(dir, 'memory'), () => {
71
+ copied = sweepBridgeObservations(dir, { days: NIGHTLY_SWEEP_DAYS });
72
+ return copied > 0;
73
+ });
74
+ console.log(
75
+ JSON.stringify({
76
+ event: 'nightly.sweep',
77
+ params: { agent: name, days: NIGHTLY_SWEEP_DAYS },
78
+ result: { copied },
79
+ }),
80
+ );
81
+ }
82
+
83
+ // A Stop hook or note that fired after the revision was read bumped it, and
84
+ // its activity is not covered by this run, so the marker stays (markerKept).
85
+ // The gate mark is taken in the same lock section, before the check.
86
+ export function removeMarkerIfUnchanged(markerPath, revision) {
87
+ try {
88
+ return withPendingMarkerLock(dirname(markerPath), () => {
89
+ const finishedAt = new Date();
90
+ if (!existsSync(markerPath)) return { finishedAt, markerKept: false };
91
+ const markerKept = Number(readJsonFile(markerPath, {}).revision) !== revision;
92
+ if (!markerKept) unlinkSync(markerPath);
93
+ return { finishedAt, markerKept };
94
+ });
95
+ } catch (error) {
96
+ if (error.code !== 'lock_timeout') throw error;
97
+ // A live writer holds the lock and may be mid-section, so the marker stays (fail open).
98
+ console.error(JSON.stringify({ event: 'nightly.marker_lock_timeout', params: { markerPath } }));
99
+ return { finishedAt: new Date(), markerKept: true };
100
+ }
101
+ }
102
+
103
+ const DAY_MS = 24 * 60 * 60 * 1000;
104
+ const CLEAN_STATUSES = new Set(['pass', 'skipped_no_input']);
105
+ const NOTE_STAMP_RE = /^- (\d{4}-\d\d-\d\d \d\d:\d\d)/gm;
106
+
107
+ // Whether a nightly has anything to consolidate: input newer than the last
108
+ // clean record, notes over their cap, or a note stamp a week old. Any doubt
109
+ // (no clean record, a kept marker, a read error) answers true, so a night is
110
+ // never lost. A record without markerKept predates it, so it counts as kept.
111
+ //
112
+ // No input is lost, by construction. A writer appends and bumps the marker in
113
+ // one lock section (markPendingConsolidation's `write`), and on a lock timeout
114
+ // it writes nothing. Against a pass: a writer done before the revision read is
115
+ // in what the child read; one that bumps before the remover's check keeps the
116
+ // marker, and the record's markerKept launches; one after that lock section is
117
+ // newer than the mark taken inside it. Against a skip: its mark comes before
118
+ // these reads, so an append they miss is newer than the mark, and a bump
119
+ // before its remover's check keeps the marker, which markerKept launches.
120
+ function nightlyHasWork(dir) {
121
+ try {
122
+ const last = readJsonFile(recordPath(dir), null);
123
+ const since = Date.parse(last?.finishedAt);
124
+ if (!CLEAN_STATUSES.has(last?.status) || last.markerKept !== false) return true;
125
+ if (Number.isNaN(since)) return true;
126
+ const notes = surfacesFor(dir).find((entry) => entry.name === 'notes');
127
+ const text = readFileSync(notes.path, 'utf8');
128
+ if (estimateTokens(text) > notes.cap || hasStaleStamp(text)) return true;
129
+ return [notes.path, ...observationFiles(dir)].some((path) => statSync(path).mtimeMs > since);
130
+ } catch {
131
+ return true;
132
+ }
133
+ }
134
+
135
+ function hasStaleStamp(text) {
136
+ const weekAgo = Date.now() - 7 * DAY_MS;
137
+ return [...text.matchAll(NOTE_STAMP_RE)].some(
138
+ ([, stamp]) => new Date(stamp.replace(' ', 'T')).getTime() < weekAgo,
139
+ );
140
+ }
141
+
142
+ function observationFiles(dir) {
143
+ const observations = join(dir, 'memory', 'observations');
144
+ if (!existsSync(observations)) return [];
145
+ return readdirSync(observations)
146
+ .filter((file) => file.endsWith('.jsonl'))
147
+ .map((file) => join(observations, file));
148
+ }
149
+
150
+ // No marker means no input was consumed, so the gate mark stays: a note whose
151
+ // marker bump lands after this check must still be newer than the record.
152
+ export function skipNightlyWithoutMarker(attempt) {
153
+ console.log(
154
+ `Skipping nightly consolidation for ${attempt.name}: no new session activity since the last run.`,
155
+ );
156
+ recordNightlyOutcome(attempt, {
157
+ status: 'skipped_no_input',
158
+ reason: 'no_pending_marker',
159
+ keepMark: true,
160
+ });
161
+ printHelmSkip('no_pending_input', attempt.name);
162
+ }
163
+
164
+ // True when it skipped: the run is recorded and the marker it covered removed.
165
+ export function skipNightlyWithoutWork(attempt, markerPath, revision) {
166
+ // The record ends when the check began, so input that lands during it is newer.
167
+ const checkedAt = new Date();
168
+ if (nightlyHasWork(attempt.dir)) return false;
169
+ console.log(`Skipping nightly consolidation for ${attempt.name}: nothing new to consolidate.`);
170
+ // Reconciled before the record, so the record says whether the marker stayed.
171
+ const { markerKept } = removeMarkerIfUnchanged(markerPath, revision);
172
+ recordNightlyOutcome(attempt, {
173
+ status: 'skipped_no_input',
174
+ reason: 'nothing_to_do',
175
+ finishedAt: checkedAt,
176
+ markerKept,
177
+ });
178
+ printHelmSkip('nothing_to_do', attempt.name);
179
+ return true;
180
+ }
181
+
182
+ /** Nightly statuses doctor flags for the operator. */
183
+ export const FLAGGED_NIGHTLY_STATUSES = new Set(['failed', 'missing']);
184
+
185
+ // What doctor reports for one agent: how its last nightly ended, or `missing`
186
+ // when activity is pending and no nightly has ever run. Null means neither.
187
+ export function nightlyStatus(dir) {
188
+ const last = readJsonFile(recordPath(dir), null);
189
+ if (last?.status) {
190
+ // A no-marker skip keeps an older finishedAt, so the later time is its end.
191
+ const at = [last.startedAt, last.finishedAt].filter(Boolean).sort().at(-1) ?? null;
192
+ return { status: last.status, at, reason: last.reason ?? null };
193
+ }
194
+ if (!existsSync(join(dir, 'memory', 'pending-consolidation.json'))) return null;
195
+ return { status: 'missing', at: null, reason: 'pending_without_receipt' };
196
+ }
197
+
198
+ // One record and one outcome line per attempt: how it ended, nothing it judged.
199
+ // The gate's mark is finishedAt plus markerKept; `keepMark` carries the
200
+ // previous record's. markerKept defaults to true: only a pass or skip whose
201
+ // revision held shows the record covers the pending marker.
202
+ export function recordNightlyOutcome(attempt, outcome) {
203
+ const finishedAt = outcome.finishedAt ?? new Date();
204
+ const path = recordPath(attempt.dir);
205
+ const previous = outcome.keepMark ? readJsonFile(path, null) : null;
206
+ const last = {
207
+ status: outcome.status,
208
+ startedAt: attempt.startedAt.toISOString(),
209
+ finishedAt: outcome.keepMark ? (previous?.finishedAt ?? null) : finishedAt.toISOString(),
210
+ markerKept: outcome.keepMark ? previous?.markerKept !== false : (outcome.markerKept ?? true),
211
+ provider: attempt.provider,
212
+ exitCode: outcome.exitCode ?? null,
213
+ reason: outcome.reason,
214
+ memory: attempt.memory ?? null,
215
+ };
216
+ mkdirSync(join(attempt.dir, 'memory'), { recursive: true });
217
+ const tempPath = `${path}.${process.pid}.tmp`;
218
+ writeFileSync(tempPath, JSON.stringify(last, null, 2) + '\n');
219
+ renameSync(tempPath, path);
220
+ console.log(
221
+ JSON.stringify({
222
+ event: 'nightly.outcome',
223
+ params: { agent: attempt.name, status: outcome.status, reason: outcome.reason },
224
+ result: { exitCode: last.exitCode, markerKept: last.markerKept },
225
+ latencyMs: finishedAt.getTime() - attempt.startedAt.getTime(),
226
+ }),
227
+ );
228
+ }
@@ -1,6 +1,7 @@
1
- import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { basename, join, resolve } from 'node:path';
3
- import { AGENTS_ROOT, CLI_NAME, agentDir, isInside, validateName } from './core.mjs';
3
+ import { markPendingConsolidation } from '../../hooks/memory_store.mjs';
4
+ import { ACADEMY_HOME, AGENTS_ROOT, CLI_NAME, agentDir, isInside, validateName } from './core.mjs';
4
5
  import { ownershipFault } from './lifecycle.mjs';
5
6
 
6
7
  // `notes` is not a published contract command, so it has no envelope to answer
@@ -30,7 +31,7 @@ function containedAgentDir(candidate) {
30
31
  return dir;
31
32
  }
32
33
  const fault = ownershipFault(basename(real), real);
33
- if (fault) notesFault('not_academy_owned', `${fault} at ${real}`);
34
+ if (fault) notesFault('not_academy_owned', `${real}: ${fault}`);
34
35
  return real;
35
36
  }
36
37
 
@@ -66,16 +67,65 @@ function localNoteStamp(d = new Date()) {
66
67
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
67
68
  }
68
69
 
69
- export function notesAdd(name, text) {
70
- if (!text || !text.trim()) {
70
+ // Every line terminator and vertical whitespace a Markdown reader may split on:
71
+ // LF, CR, VT, FF, NEL, U+2028 and U+2029, with the blanks around them.
72
+ const LINE_BREAKS = /[ \t]*[\n\r\v\f\u0085\u2028\u2029]+\s*/g;
73
+
74
+ function toOneLine(text) {
75
+ return text.replace(LINE_BREAKS, ' ').trim();
76
+ }
77
+
78
+ function endsMidLine(path) {
79
+ if (!existsSync(path)) return false;
80
+ const text = readFileSync(path, 'utf8');
81
+ return text.length > 0 && !text.endsWith('\n');
82
+ }
83
+
84
+ // A proposal about the user lands at the end of the shared user.md, tagged
85
+ // `[proposed]` and with the agent that wrote it. One line, so the text cannot
86
+ // open a line of its own.
87
+ function userProposalAdd(dir, text) {
88
+ const agent = basename(dir);
89
+ const line = `- [proposed] ${localNoteStamp()} ${agent}: ${toOneLine(text)}\n`;
90
+ const userPath = join(ACADEMY_HOME, 'user.md');
91
+ mkdirSync(ACADEMY_HOME, { recursive: true });
92
+ appendFileSync(userPath, `${endsMidLine(userPath) ? '\n' : ''}${line}`);
93
+ console.log(
94
+ JSON.stringify({
95
+ event: 'notes.user_proposed',
96
+ params: { agent },
97
+ result: { path: userPath },
98
+ }),
99
+ );
100
+ console.log(`Proposed → ${userPath}`);
101
+ }
102
+
103
+ export function notesAdd(name, text, { user = false } = {}) {
104
+ // Judge emptiness after the line-break collapse: trim() alone keeps NEL.
105
+ if (!text || !toOneLine(text)) {
71
106
  console.error('Error: note text required.');
72
- console.error(`Usage: ${CLI_NAME} notes add [<agent>] "text"`);
107
+ console.error(`Usage: ${CLI_NAME} notes add [<agent>] [--user] "text"`);
73
108
  process.exit(1);
74
109
  }
75
110
  const dir = ensureAgentHome(resolveNotesAgentDir(name));
111
+ if (user) {
112
+ userProposalAdd(dir, text);
113
+ return;
114
+ }
76
115
  const notesPath = join(dir, 'notes.md');
77
- // Append-only: never read or rewrite the whole file (spec §Behavior).
78
- appendFileSync(notesPath, `- ${localNoteStamp()}: ${text}\n`);
116
+ // An agent made before memory/ existed has no directory to hold the marker.
117
+ const memoryDir = join(dir, 'memory');
118
+ mkdirSync(memoryDir, { recursive: true });
119
+ // The note commits with the marker bump that arms tldr's file_exists gate,
120
+ // so the nightly curator picks it up. Append-only: never read or rewrite
121
+ // the whole file (spec §Behavior).
122
+ const noted = markPendingConsolidation(memoryDir, () =>
123
+ appendFileSync(notesPath, `- ${localNoteStamp()}: ${text}\n`),
124
+ );
125
+ if (!noted) {
126
+ console.error('Error: the memory lock is busy, so the note was not saved. Try again.');
127
+ process.exit(1);
128
+ }
79
129
  console.log(`Noted → ${notesPath}`);
80
130
  }
81
131
 
@@ -0,0 +1,181 @@
1
+ import { readAgentYaml } from './yaml.mjs';
2
+
3
+ // An Owner's working terms live as top-level agent.yaml scalars. A missing
4
+ // scalar reads as the conservative default: business hours, both approval
5
+ // gates, and no merge right. `create --kind owner` writes these same defaults.
6
+
7
+ const systemTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
8
+
9
+ function ownerDefaults() {
10
+ return {
11
+ owner_hours: 'weekdays 09:00-17:00',
12
+ owner_timezone: systemTimezone(),
13
+ owner_heartbeat: '30m',
14
+ owner_gates: 'proposal, pr',
15
+ owner_may_merge: 'false',
16
+ owner_contact: 'email',
17
+ };
18
+ }
19
+
20
+ // The docs example carries inline comments after quoted values; the shared
21
+ // reader keeps both, so strip the comment and then the quotes it left inside.
22
+ function cleanScalar(value) {
23
+ return value
24
+ .replace(/\s+#.*$/, '')
25
+ .trim()
26
+ .replace(/^["']|["']$/g, '');
27
+ }
28
+
29
+ export function ownerConfig(dir) {
30
+ const yaml = readAgentYaml(dir);
31
+ const config = ownerDefaults();
32
+ for (const key of Object.keys(config)) {
33
+ const value = yaml[key] === undefined ? '' : cleanScalar(yaml[key]);
34
+ if (value) config[key] = value;
35
+ }
36
+ return config;
37
+ }
38
+
39
+ const OWNER_HOURS_WINDOW = /^(daily|weekdays)\s+(\d{2}):(\d{2})-(\d{2}):(\d{2})$/;
40
+
41
+ const toMinutes = (hours, minutes) => Number(hours) * 60 + Number(minutes);
42
+
43
+ // The one reader of owner_hours: the daily job's opening time and the heartbeat
44
+ // cron window both come from this parse, so a form that registers a daily job
45
+ // also registers a heartbeat. Forms: "24x7", or
46
+ // "daily|weekdays HH:MM-HH:MM". The start is inside the window, the end is not;
47
+ // "24:00" is a valid end; an end before the start runs past midnight. Anything
48
+ // else is null, and each caller decides what that means.
49
+ function parseOwnerHours(text) {
50
+ const hours = text.trim().toLowerCase();
51
+ if (hours === '24x7') return { always: true };
52
+ const match = OWNER_HOURS_WINDOW.exec(hours);
53
+ if (!match) return null;
54
+ const [, days, startH, startM, endH, endM] = match;
55
+ const start = toMinutes(startH, startM);
56
+ const end = toMinutes(endH, endM);
57
+ const valid = Number(startM) < 60 && Number(endM) < 60 && start < 24 * 60 && end <= 24 * 60;
58
+ if (!valid || start === end) return null;
59
+ return { weekdaysOnly: days === 'weekdays', start, end };
60
+ }
61
+
62
+ // "24x7" has no opening time, so its daily open uses the hour the daily work
63
+ // session has always started at.
64
+ const ALWAYS_OPEN_CRON = '0 6 * * *';
65
+
66
+ // The daily open fires at the start of the owner_hours window, on the window's
67
+ // days. Null when the window is not one Academy reads, so the caller can say so
68
+ // instead of scheduling a guess.
69
+ export function ownerDailyCron(hours) {
70
+ const window = parseOwnerHours(hours);
71
+ if (!window) return null;
72
+ if (window.always) return ALWAYS_OPEN_CRON;
73
+ const days = window.weekdaysOnly ? '1-5' : '*';
74
+ return `${window.start % 60} ${Math.floor(window.start / 60)} * * ${days}`;
75
+ }
76
+
77
+ const INTERVAL = /^(\d+)(m|h)$/;
78
+ // A stepped field stays a range: cron reads a stepped single value `9/2` as
79
+ // 9 through 23, outside the window.
80
+ const span = (from, to, step) => (from === to && !step ? `${from}` : `${from}-${to}${step}`);
81
+
82
+ // The hours a heartbeat may fire in, rounded inward to whole hours so no beat
83
+ // lands outside the window. An overnight window is two spans on the calendar
84
+ // day cron reads. Null when no whole hour fits.
85
+ function heartbeatHourSpans({ start, end }) {
86
+ const first = Math.ceil(start / 60);
87
+ const stop = Math.floor(end / 60);
88
+ if (start < end) return first < stop ? [[first, stop - 1]] : null;
89
+ const spans = [];
90
+ if (first <= 23) spans.push([first, 23]);
91
+ if (stop > 0) spans.push([0, stop - 1]);
92
+ return spans.length ? spans : null;
93
+ }
94
+
95
+ // The cron minute field and hour step for owner_heartbeat. Intervals that
96
+ // divide an hour fire every N minutes; whole hours that divide a day fire
97
+ // every N hours. Each beat is offset by half the interval, so the first one
98
+ // does not fire at the same minute as the daily open. Null for anything else.
99
+ function heartbeatCadence(every) {
100
+ const interval = INTERVAL.exec((every ?? '').trim().toLowerCase());
101
+ if (!interval) return null;
102
+ const minutes = Number(interval[1]) * (interval[2] === 'h' ? 60 : 1);
103
+ if (minutes > 0 && minutes < 60 && 60 % minutes === 0) {
104
+ return { minute: `${Math.floor(minutes / 2)}-59/${minutes}`, step: '' };
105
+ }
106
+ const hours = minutes / 60;
107
+ if (!Number.isInteger(hours) || hours < 1 || 24 % hours !== 0) return null;
108
+ return { minute: '30', step: hours > 1 ? `/${hours}` : '' };
109
+ }
110
+
111
+ // One cron line for the heartbeat: tldr then keeps it inside owner_hours in
112
+ // owner_timezone, and `academy run` needs no hours guard. Null when the window
113
+ // or the interval is one cron cannot hold.
114
+ export function ownerHeartbeatCron(hours, every) {
115
+ const window = parseOwnerHours(hours ?? '');
116
+ const cadence = heartbeatCadence(every);
117
+ if (!window || !cadence) return null;
118
+ const { minute, step } = cadence;
119
+ if (window.always) return `${minute} *${step} * * *`;
120
+ const spans = heartbeatHourSpans(window);
121
+ if (!spans) return null;
122
+ const hourField = spans.map(([from, to]) => span(from, to, step)).join(',');
123
+ return `${minute} ${hourField} * * ${window.weekdaysOnly ? '1-5' : '*'}`;
124
+ }
125
+
126
+ function ownerGateList(gates) {
127
+ return gates
128
+ .split(/[\s,]+/)
129
+ .map((gate) => gate.toLowerCase())
130
+ .filter((gate) => gate && gate !== 'none');
131
+ }
132
+
133
+ const GATE_RULES = {
134
+ proposal:
135
+ '- `proposal` gate: before you start a new initiative (a Beads epic under a Workstream), write the ' +
136
+ 'rationale and its link to your mandate and strategy, run ' +
137
+ '`bd gate create --type=human --blocks <epic-id> --json`, send the request, and stop work on that ' +
138
+ 'initiative. Wait until the user resolves the gate. Tasks inside an approved initiative need no gate.',
139
+ pr:
140
+ '- `pr` gate: open the PR, block the item with ' +
141
+ '`bd gate create --type=gh:pr --await-id=<PR URL> --blocks <id> --json`, and stop. ' +
142
+ 'Do not merge it; the gate closes when the user merges it.',
143
+ };
144
+
145
+ const gateRule = (gate) =>
146
+ GATE_RULES[gate] ??
147
+ `- \`${gate}\` gate: stop at this boundary and ask the user. Do not pass it yourself.`;
148
+
149
+ // The rules this config grants, rendered as plain lines so a run never has to
150
+ // work out its own authority from the raw scalars.
151
+ function ownerAuthority({ owner_gates, owner_may_merge }) {
152
+ const gates = ownerGateList(owner_gates);
153
+ const lines = gates.map(gateRule);
154
+ if (gates.length === 0)
155
+ lines.push('- No gates: act without asking, inside the mandate in mandate.md.');
156
+ if (gates.includes('pr')) return lines.join('\n');
157
+ lines.push(
158
+ owner_may_merge === 'true'
159
+ ? '- Merge: merge your own PR only after your merge bar passes. Record its evidence first.'
160
+ : '- Merge: never merge. Open the PR and report it.',
161
+ );
162
+ return lines.join('\n');
163
+ }
164
+
165
+ export function ownerSkillValues(dir) {
166
+ const config = ownerConfig(dir);
167
+ return { ...config, owner_authority: ownerAuthority(config) };
168
+ }
169
+
170
+ export function ownerYamlBlock() {
171
+ const defaults = ownerDefaults();
172
+ return `# Owner terms — work hours and timezone, heartbeat interval, approval gates
173
+ # (proposal, pr, or none), merge right, and how to reach the user.
174
+ owner_hours: "${defaults.owner_hours}"
175
+ owner_timezone: ${defaults.owner_timezone}
176
+ owner_heartbeat: ${defaults.owner_heartbeat}
177
+ owner_gates: ${defaults.owner_gates}
178
+ owner_may_merge: ${defaults.owner_may_merge}
179
+ owner_contact: ${defaults.owner_contact}
180
+ `;
181
+ }