@alexandrealvaro/agentic 0.9.3-beta.1 → 0.9.4-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexandrealvaro/agentic",
3
- "version": "0.9.3-beta.1",
3
+ "version": "0.9.4-beta.1",
4
4
  "description": "Bootstrap and audit AGENTS.md, ARCHITECTURE.md, ADRs, skills, and subagents for engineering production code with LLMs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,6 +48,7 @@ const ROOT_DOC_LABEL = {
48
48
  updated: '~ ',
49
49
  unchanged: '· ',
50
50
  skipped: '! ',
51
+ 'kept-stale': '! ',
51
52
  absent: '',
52
53
  };
53
54
 
@@ -47,6 +47,7 @@ const ACTION_SYMBOL = {
47
47
  kept: '·',
48
48
  skipped: '!',
49
49
  removed: '-',
50
+ 'removed-missing': '?',
50
51
  'orphan-kept': '?',
51
52
  };
52
53
 
@@ -55,6 +56,7 @@ const ROOT_DOC_LABEL = {
55
56
  updated: '~ ',
56
57
  unchanged: '· ',
57
58
  skipped: '! ',
59
+ 'kept-stale': '! ',
58
60
  absent: '',
59
61
  };
60
62
 
@@ -86,13 +88,24 @@ function skillsForAgent(agent, profileName, optedSkills) {
86
88
  return [...universal, ...conditional];
87
89
  }
88
90
 
89
- function previousAgentsFromStates(cwd) {
90
- const out = [];
91
+ /**
92
+ * Load every per-agent state file once. Returns `{ statesByAgent, agents }`
93
+ * where `agents` lists the agents whose state files exist on disk. Avoids
94
+ * the prior pattern of calling `loadState` twice per agent (once for
95
+ * presence detection, once for content); also surfaces malformed-state
96
+ * errors with the loader's own context, in a single pass.
97
+ */
98
+ function loadStatesOnce(cwd) {
99
+ const statesByAgent = {};
100
+ const agents = [];
91
101
  for (const agent of VALID_AGENTS) {
92
102
  const state = loadState(cwd, agent);
93
- if (state) out.push(agent);
103
+ if (state) {
104
+ statesByAgent[agent] = state;
105
+ agents.push(agent);
106
+ }
94
107
  }
95
- return out;
108
+ return { statesByAgent, agents };
96
109
  }
97
110
 
98
111
  function previouslyOptedConditional(previousStates, currentAgents, profileName) {
@@ -140,18 +153,23 @@ export async function updateCommand(opts) {
140
153
  }
141
154
 
142
155
  const cwd = process.cwd();
143
- const interactive = process.stdout.isTTY && !opts.yes && !opts.agent;
156
+ // `--agent` is purely a narrowing flag and does not imply non-interactive
157
+ // intent. Only `--yes` or a non-TTY shell suppress the TUI per ADR-0009.
158
+ const interactive = process.stdout.isTTY && !opts.yes;
144
159
  const dryRun = Boolean(opts.dryRun);
145
160
  const force = Boolean(opts.force);
146
161
 
147
162
  const detectedAgents = detectAgents(cwd);
148
163
  const features = detectFeatures(cwd);
149
- const previousAgents = previousAgentsFromStates(cwd);
164
+ const { statesByAgent, agents: previousAgents } = loadStatesOnce(cwd);
150
165
 
151
166
  const agents = resolveAgents(opts.agent, detectedAgents, previousAgents);
167
+ // previousStates is the per-agent slice of statesByAgent restricted to the
168
+ // agents the current invocation targets. Agents outside the slice keep
169
+ // their state file untouched on disk.
152
170
  const previousStates = {};
153
171
  for (const agent of agents) {
154
- previousStates[agent] = loadState(cwd, agent);
172
+ previousStates[agent] = statesByAgent[agent] ?? null;
155
173
  }
156
174
 
157
175
  const profileName = profileFromStates(previousStates, agents);
@@ -282,13 +300,24 @@ export async function updateCommand(opts) {
282
300
  }
283
301
  : async () => true;
284
302
 
285
- const rootDocAction = !dryRun
286
- ? await updateRootDoc({
287
- cwd,
288
- skills: skillDisplayOrder,
289
- confirmAppend,
290
- })
291
- : { type: 'absent', path: null };
303
+ const confirmRootDocReplace = interactive
304
+ ? async (path) => {
305
+ const answer = await p.confirm({
306
+ message: `${path}: managed section diverged on disk. Regenerate it? (any edits between the agentic-managed-skills markers will be lost)`,
307
+ initialValue: false,
308
+ });
309
+ if (p.isCancel(answer)) return false;
310
+ return answer;
311
+ }
312
+ : async () => Boolean(force);
313
+
314
+ const rootDocAction = await updateRootDoc({
315
+ cwd,
316
+ skills: skillDisplayOrder,
317
+ confirmAppend,
318
+ confirmReplace: confirmRootDocReplace,
319
+ dryRun,
320
+ });
292
321
 
293
322
  const lines = allActions.map((a) => {
294
323
  const sym = ACTION_SYMBOL[a.type] ?? '?';
@@ -356,7 +356,16 @@ export async function removeOrphanSkills({
356
356
 
357
357
  for (const f of entry.files) {
358
358
  const abs = join(cwd, f.path);
359
- if (existsSync(abs) && !dryRun) {
359
+ if (!existsSync(abs)) {
360
+ // State recorded the file at install time but it is gone on disk
361
+ // now (manually deleted, moved, or never written). Surface as a
362
+ // distinct action so the user sees the state-vs-reality mismatch
363
+ // instead of a silent "removed" line for a file that was never
364
+ // there.
365
+ actions.push({ type: 'removed-missing', path: f.path, agent });
366
+ continue;
367
+ }
368
+ if (!dryRun) {
360
369
  unlinkSync(abs);
361
370
  }
362
371
  actions.push({ type: 'removed', path: f.path, agent });
@@ -93,18 +93,23 @@ function replaceSection(body, newSection, bounds) {
93
93
  * Behaviour:
94
94
  * - No root doc → returns { type: 'absent', path: null }. Skill bootstraps create the file later.
95
95
  * - Section already present and matches → { type: 'unchanged', path }.
96
- * - Section already present and stale → updated in place → { type: 'updated', path }.
96
+ * - Section already present and stale → confirmReplace(path) is called; on true updated → { type: 'updated', path }; on false → { type: 'kept-stale', path } (managed-section diverged on disk and the user chose to keep it).
97
97
  * - Section absent → confirmAppend(path) is called; on true → appended → { type: 'appended', path }; on false → { type: 'skipped', path }.
98
+ * - dryRun true → no writes; returned `type` reflects what *would* happen.
98
99
  *
99
100
  * @param {object} opts
100
101
  * @param {string} opts.cwd Target project root.
101
102
  * @param {string[]} opts.skills Skill names actually installed (in display order).
102
103
  * @param {(path: string) => Promise<boolean>} [opts.confirmAppend] Async confirmation callback for the append-to-existing-file case. Default: skip.
104
+ * @param {(path: string) => Promise<boolean>} [opts.confirmReplace] Async confirmation callback for the replace-existing-section case (managed section present on disk but stale). Default: replace (preserves the v0.5 behaviour where update silently regenerates the managed block on every kit / skill change). Wire to a prompt to honor user edits between markers.
105
+ * @param {boolean} [opts.dryRun] When true, computes the action without writing.
103
106
  */
104
107
  export async function updateRootDoc({
105
108
  cwd,
106
109
  skills,
107
110
  confirmAppend = async () => false,
111
+ confirmReplace = async () => true,
112
+ dryRun = false,
108
113
  }) {
109
114
  const found = findRootDoc(cwd);
110
115
  if (!found) return { type: 'absent', path: null };
@@ -116,7 +121,9 @@ export async function updateRootDoc({
116
121
  if (bounds) {
117
122
  const updated = replaceSection(body, section, bounds);
118
123
  if (updated === body) return { type: 'unchanged', path: found.name };
119
- writeFileSync(found.path, updated);
124
+ const ok = await confirmReplace(found.name);
125
+ if (!ok) return { type: 'kept-stale', path: found.name };
126
+ if (!dryRun) writeFileSync(found.path, updated);
120
127
  return { type: 'updated', path: found.name };
121
128
  }
122
129
 
@@ -124,6 +131,6 @@ export async function updateRootDoc({
124
131
  if (!ok) return { type: 'skipped', path: found.name };
125
132
 
126
133
  const sep = body.endsWith('\n') ? '\n' : '\n\n';
127
- writeFileSync(found.path, body + sep + section + '\n');
134
+ if (!dryRun) writeFileSync(found.path, body + sep + section + '\n');
128
135
  return { type: 'appended', path: found.name };
129
136
  }
package/src/lib/state.js CHANGED
@@ -77,9 +77,22 @@ export function saveState(cwd, agent, state) {
77
77
  }
78
78
 
79
79
  function orderState(state) {
80
+ if (!state || typeof state !== 'object') {
81
+ throw new Error('orderState: state must be an object');
82
+ }
83
+ if (!state.skills || typeof state.skills !== 'object') {
84
+ throw new Error(
85
+ 'orderState: state.skills must be an object (got ' + typeof state.skills + ')'
86
+ );
87
+ }
80
88
  const skills = {};
81
89
  for (const skillName of Object.keys(state.skills).sort()) {
82
90
  const entry = state.skills[skillName];
91
+ if (!entry || !Array.isArray(entry.files)) {
92
+ throw new Error(
93
+ `orderState: state.skills["${skillName}"].files must be an array`
94
+ );
95
+ }
83
96
  const files = [...entry.files].sort((a, b) => a.path.localeCompare(b.path));
84
97
  skills[skillName] = {
85
98
  version: entry.version,