@jaimevalasek/aioson 1.9.3 → 1.16.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +188 -0
  2. package/README.md +44 -1
  3. package/package.json +1 -1
  4. package/src/cli.js +45 -1
  5. package/src/commands/op-capture.js +146 -0
  6. package/src/commands/op-forget.js +54 -0
  7. package/src/commands/op-identity.js +145 -0
  8. package/src/commands/op-list.js +105 -0
  9. package/src/commands/op-migrate.js +158 -0
  10. package/src/commands/op-promote.js +66 -0
  11. package/src/commands/op-reinforce.js +73 -0
  12. package/src/commands/op-show.js +71 -0
  13. package/src/commands/op-stubs.js +67 -0
  14. package/src/commands/preflight.js +6 -2
  15. package/src/commands/runtime.js +151 -0
  16. package/src/commands/state-save.js +61 -0
  17. package/src/commands/sync-agents-preflight.js +117 -3
  18. package/src/commands/workflow-next.js +64 -0
  19. package/src/handoff-contract.js +25 -0
  20. package/src/lib/agent-semantic-diff.js +199 -0
  21. package/src/operator-memory/conflict.js +202 -0
  22. package/src/operator-memory/decay.js +157 -0
  23. package/src/operator-memory/decision.js +274 -0
  24. package/src/operator-memory/identity.js +109 -0
  25. package/src/operator-memory/index-md.js +170 -0
  26. package/src/operator-memory/loader.js +106 -0
  27. package/src/operator-memory/proposal.js +179 -0
  28. package/src/operator-memory/prune.js +81 -0
  29. package/src/operator-memory/slug.js +90 -0
  30. package/src/operator-memory/storage.js +121 -0
  31. package/src/preflight-engine.js +91 -1
  32. package/template/.aioson/agents/dev.md +1 -1
  33. package/template/.aioson/agents/deyvin.md +3 -3
  34. package/template/.aioson/agents/neo.md +1 -1
  35. package/template/.aioson/agents/product.md +1 -1
  36. package/template/.aioson/agents/setup.md +1 -1
  37. package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
  38. package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
  39. package/template/AGENTS.md +23 -0
  40. package/template/CLAUDE.md +23 -0
  41. package/template/agents/_shared/memory-capture-directive.md +115 -0
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * operator-memory — proposal CRUD (Phase 2, v1.13.0).
5
+ *
6
+ * Proposals are the pending-promotion queue. Each first detection writes a
7
+ * proposal at proposals/{slug}.md with detected_count=1. Second detection
8
+ * triggers promotion (see decision.js) — proposal removed, decision created
9
+ * atomically via SQLite transaction wrapping fs ops.
10
+ *
11
+ * Schema (frontmatter-only file, no body):
12
+ * ---
13
+ * slug: ...
14
+ * signal_type: authorization | exclusion | correction | confirmation
15
+ * detected_count: 1
16
+ * first_detected: <ISO>
17
+ * last_detected: <ISO>
18
+ * quotes: [verbatim, ...] (capped at 5)
19
+ * proposal: <paraphrase>
20
+ * source_agent: <agent_name>
21
+ * proposal_fingerprint: <sha256[0..12]>
22
+ * ---
23
+ */
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+ const { ensureStorageTree, getStorageRoot } = require('./storage');
28
+ const { fingerprintProposal } = require('./slug');
29
+
30
+ const MAX_QUOTES = 5;
31
+ const VALID_SIGNAL_TYPES = ['authorization', 'exclusion', 'correction', 'confirmation'];
32
+
33
+ function proposalPath(identity, slug) {
34
+ return path.join(getStorageRoot(identity), 'proposals', `${slug}.md`);
35
+ }
36
+
37
+ function escapeYamlString(value) {
38
+ const s = String(value || '');
39
+ // Simple: wrap in single quotes; escape embedded single quotes
40
+ return `'${s.replace(/'/g, "''")}'`;
41
+ }
42
+
43
+ function quotesToYaml(quotes) {
44
+ if (!quotes || quotes.length === 0) return '[]';
45
+ return '\n' + quotes.map((q) => ` - ${escapeYamlString(q)}`).join('\n');
46
+ }
47
+
48
+ function serializeProposal(data) {
49
+ return [
50
+ '---',
51
+ `slug: ${data.slug}`,
52
+ `signal_type: ${data.signal_type}`,
53
+ `detected_count: ${data.detected_count}`,
54
+ `first_detected: ${data.first_detected}`,
55
+ `last_detected: ${data.last_detected}`,
56
+ `quotes:${quotesToYaml(data.quotes)}`,
57
+ `proposal: ${escapeYamlString(data.proposal)}`,
58
+ `source_agent: ${data.source_agent}`,
59
+ `proposal_fingerprint: ${data.proposal_fingerprint}`,
60
+ '---',
61
+ ''
62
+ ].join('\n');
63
+ }
64
+
65
+ function parseProposalFrontmatter(content) {
66
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
67
+ if (!match) return null;
68
+ const block = match[1];
69
+ const out = {};
70
+ let inQuotes = false;
71
+ let quotes = [];
72
+ for (const rawLine of block.split('\n')) {
73
+ if (rawLine.startsWith('quotes:')) {
74
+ const after = rawLine.slice('quotes:'.length).trim();
75
+ if (after === '[]' || after === '') {
76
+ if (after === '[]') { inQuotes = false; out.quotes = []; continue; }
77
+ inQuotes = true; quotes = []; continue;
78
+ }
79
+ inQuotes = true; quotes = []; continue;
80
+ }
81
+ if (inQuotes) {
82
+ const m = rawLine.match(/^\s+-\s+'?([\s\S]*?)'?\s*$/);
83
+ if (m) {
84
+ quotes.push(m[1].replace(/''/g, "'"));
85
+ continue;
86
+ } else {
87
+ inQuotes = false;
88
+ out.quotes = quotes;
89
+ // fall through to parse this line as a regular field
90
+ }
91
+ }
92
+ const fieldMatch = rawLine.match(/^([a-z_]+):\s*(.*)$/);
93
+ if (fieldMatch) {
94
+ const [, key, rawValue] = fieldMatch;
95
+ let value = rawValue.trim();
96
+ if (value.startsWith("'") && value.endsWith("'")) {
97
+ value = value.slice(1, -1).replace(/''/g, "'");
98
+ }
99
+ if (/^\d+$/.test(value)) value = Number(value);
100
+ out[key] = value;
101
+ }
102
+ }
103
+ if (inQuotes) out.quotes = quotes;
104
+ if (!out.quotes) out.quotes = [];
105
+ return out;
106
+ }
107
+
108
+ function readProposal(identity, slug) {
109
+ const filePath = proposalPath(identity, slug);
110
+ if (!fs.existsSync(filePath)) return null;
111
+ const content = fs.readFileSync(filePath, 'utf8');
112
+ return parseProposalFrontmatter(content);
113
+ }
114
+
115
+ function writeProposal(identity, data) {
116
+ ensureStorageTree(identity);
117
+ const filePath = proposalPath(identity, data.slug);
118
+ const tmpPath = `${filePath}.tmp`;
119
+ fs.writeFileSync(tmpPath, serializeProposal(data), 'utf8');
120
+ fs.renameSync(tmpPath, filePath);
121
+ return filePath;
122
+ }
123
+
124
+ function deleteProposal(identity, slug) {
125
+ const filePath = proposalPath(identity, slug);
126
+ if (fs.existsSync(filePath)) {
127
+ fs.unlinkSync(filePath);
128
+ return true;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ function captureSignal({ identity, slug, signal_type, quote, proposal, source_agent }) {
134
+ if (!VALID_SIGNAL_TYPES.includes(signal_type)) {
135
+ throw new Error(`Invalid signal_type '${signal_type}'. Must be one of: ${VALID_SIGNAL_TYPES.join(', ')}`);
136
+ }
137
+ const now = new Date().toISOString();
138
+ const existing = readProposal(identity, slug);
139
+
140
+ if (existing) {
141
+ existing.detected_count = Number(existing.detected_count || 1) + 1;
142
+ existing.last_detected = now;
143
+ const trimmedQuote = String(quote || '').trim();
144
+ if (trimmedQuote && !existing.quotes.includes(trimmedQuote)) {
145
+ existing.quotes.push(trimmedQuote);
146
+ if (existing.quotes.length > MAX_QUOTES) {
147
+ existing.quotes = existing.quotes.slice(-MAX_QUOTES);
148
+ }
149
+ }
150
+ writeProposal(identity, existing);
151
+ return { proposal: existing, isNew: false };
152
+ }
153
+
154
+ const fresh = {
155
+ slug,
156
+ signal_type,
157
+ detected_count: 1,
158
+ first_detected: now,
159
+ last_detected: now,
160
+ quotes: quote ? [String(quote).trim()].filter(Boolean) : [],
161
+ proposal,
162
+ source_agent: source_agent || 'unknown',
163
+ proposal_fingerprint: fingerprintProposal(proposal)
164
+ };
165
+ writeProposal(identity, fresh);
166
+ return { proposal: fresh, isNew: true };
167
+ }
168
+
169
+ module.exports = {
170
+ captureSignal,
171
+ readProposal,
172
+ writeProposal,
173
+ deleteProposal,
174
+ proposalPath,
175
+ serializeProposal,
176
+ parseProposalFrontmatter,
177
+ MAX_QUOTES,
178
+ VALID_SIGNAL_TYPES
179
+ };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * operator-memory — hard cap enforcement (Phase 5, v1.16.0).
5
+ *
6
+ * PMD-04: 10k memories per operator identity. When op:promote would push
7
+ * count > cap, prune oldest non-identity decisions first; identity-category
8
+ * decisions are NEVER auto-pruned.
9
+ *
10
+ * Override cap for tests via AIOSON_OPERATOR_MAX_DECISIONS env var.
11
+ */
12
+
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+ const { getStorageRoot } = require('./storage');
16
+ const { readDecision, decisionPath, historyPath, forgetEntry } = require('./decision');
17
+ const { listDecisionSlugs } = require('./index-md');
18
+
19
+ const DEFAULT_MAX_DECISIONS = 10_000;
20
+
21
+ function getMaxDecisions() {
22
+ const override = process.env.AIOSON_OPERATOR_MAX_DECISIONS;
23
+ if (override && !Number.isNaN(Number(override))) {
24
+ return Number(override);
25
+ }
26
+ return DEFAULT_MAX_DECISIONS;
27
+ }
28
+
29
+ /**
30
+ * Count active decisions for an identity.
31
+ */
32
+ function countDecisions(identity) {
33
+ return listDecisionSlugs(identity).length;
34
+ }
35
+
36
+ /**
37
+ * Identify pruning candidates: non-identity-category decisions sorted by
38
+ * last_reinforced ASC (oldest first).
39
+ *
40
+ * @param {number} need — number of pruning slots needed
41
+ * @returns {Array<{slug, category, last_reinforced}>}
42
+ */
43
+ function pickPruneCandidates(identity, need) {
44
+ if (need <= 0) return [];
45
+ const slugs = listDecisionSlugs(identity);
46
+ const candidates = [];
47
+ for (const slug of slugs) {
48
+ const d = readDecision(identity, slug);
49
+ if (!d) continue;
50
+ if (d.category === 'identity') continue; // PMD-04: never auto-prune identity
51
+ candidates.push({ slug, category: d.category, last_reinforced: d.last_reinforced || d.promoted_at });
52
+ }
53
+ candidates.sort((a, b) => String(a.last_reinforced || '').localeCompare(String(b.last_reinforced || '')));
54
+ return candidates.slice(0, need);
55
+ }
56
+
57
+ /**
58
+ * Enforce hard cap before allowing an op:promote to proceed.
59
+ * Returns array of pruned slug names (empty when under cap).
60
+ */
61
+ function enforceCap(identity, options = {}) {
62
+ const cap = options.cap || getMaxDecisions();
63
+ const current = countDecisions(identity);
64
+ if (current < cap) return [];
65
+ const need = current - cap + 1; // +1 to make room for the incoming promote
66
+ const candidates = pickPruneCandidates(identity, need);
67
+ const pruned = [];
68
+ for (const c of candidates) {
69
+ const result = forgetEntry(identity, c.slug);
70
+ if (result.mode === 'decision') pruned.push(c.slug);
71
+ }
72
+ return pruned;
73
+ }
74
+
75
+ module.exports = {
76
+ enforceCap,
77
+ countDecisions,
78
+ pickPruneCandidates,
79
+ getMaxDecisions,
80
+ DEFAULT_MAX_DECISIONS
81
+ };
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * operator-memory — deterministic slug derivation (Phase 2, v1.13.0).
5
+ *
6
+ * Same proposal text → same slug. Collision suffix appended if slug already
7
+ * taken by a different proposal text (sha256 fingerprint comparison).
8
+ *
9
+ * AC-P2-02: derivation is deterministic + collision-safe.
10
+ */
11
+
12
+ const crypto = require('node:crypto');
13
+
14
+ const MAX_SLUG_LENGTH = 40;
15
+ const STOPWORDS = new Set([
16
+ 'a', 'an', 'the', 'and', 'or', 'of', 'to', 'for', 'in', 'on', 'with', 'is', 'be',
17
+ 'que', 'de', 'em', 'para', 'sem', 'com', 'um', 'uma', 'os', 'as', 'no', 'na'
18
+ ]);
19
+
20
+ function normalize(text) {
21
+ return String(text || '')
22
+ .normalize('NFD')
23
+ .replace(/[̀-ͯ]/g, '') // strip diacritics
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9\s-]+/g, ' ') // non-alnum -> space
26
+ .split(/\s+/)
27
+ .filter((w) => w && !STOPWORDS.has(w))
28
+ .join('-')
29
+ .replace(/-+/g, '-')
30
+ .replace(/^-|-$/g, '');
31
+ }
32
+
33
+ function truncateAtBoundary(slug, max) {
34
+ if (slug.length <= max) return slug;
35
+ const sliced = slug.slice(0, max);
36
+ const lastDash = sliced.lastIndexOf('-');
37
+ if (lastDash > max * 0.6) {
38
+ return sliced.slice(0, lastDash);
39
+ }
40
+ return sliced;
41
+ }
42
+
43
+ function fingerprintProposal(text) {
44
+ return crypto.createHash('sha256').update(String(text || '').trim()).digest('hex').slice(0, 12);
45
+ }
46
+
47
+ /**
48
+ * Derive a slug from a proposal text.
49
+ *
50
+ * @param {string} proposalText — the canonical proposal paraphrase
51
+ * @param {(slug: string) => string|null} existsCheck — optional callback returning
52
+ * the existing proposal's fingerprint for `slug` if taken, or null. Used to
53
+ * detect same-slug-different-text collisions.
54
+ * @returns {string} slug
55
+ */
56
+ function deriveSlug(proposalText, existsCheck = null) {
57
+ const normalized = normalize(proposalText);
58
+ const base = normalized === '' ? 'untitled' : truncateAtBoundary(normalized, MAX_SLUG_LENGTH);
59
+ const proposalFingerprint = fingerprintProposal(proposalText);
60
+
61
+ if (typeof existsCheck !== 'function') {
62
+ return base;
63
+ }
64
+
65
+ let candidate = base;
66
+ let counter = 2;
67
+ while (counter < 100) {
68
+ const existingFingerprint = existsCheck(candidate);
69
+ if (existingFingerprint === null || existingFingerprint === undefined) {
70
+ // slug available
71
+ return candidate;
72
+ }
73
+ if (existingFingerprint === proposalFingerprint) {
74
+ // same proposal text — reuse slug (idempotent capture)
75
+ return candidate;
76
+ }
77
+ // collision: different proposal at same slug — append counter
78
+ candidate = `${base}-${counter}`;
79
+ counter += 1;
80
+ }
81
+ // extreme collision fallback (should never hit in practice)
82
+ return `${base}-${proposalFingerprint.slice(0, 8)}`;
83
+ }
84
+
85
+ module.exports = {
86
+ deriveSlug,
87
+ normalize,
88
+ fingerprintProposal,
89
+ MAX_SLUG_LENGTH
90
+ };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * operator-memory — storage tree + _index.sqlite (Phase 1, v1.12.0).
5
+ *
6
+ * Markdown is source-of-truth (PMD-AN-06); SQLite is regenerable index (FTS5).
7
+ * Phase 1 creates the schema (operators + decisions_fts virtual table). Phase 2
8
+ * activates FTS5 mirroring from op:capture/op:promote.
9
+ *
10
+ * Storage tree (architecture-operator-memory.md § Storage architecture):
11
+ * ~/.aioson/operators/
12
+ * ├── _index.sqlite shared across identities (PMD-01 hybrid)
13
+ * └── {identity}/
14
+ * ├── decisions/ Phase 2 populates
15
+ * ├── proposals/ Phase 2 populates
16
+ * └── history/ Phase 2+5 populates
17
+ */
18
+
19
+ const fs = require('node:fs');
20
+ const path = require('node:path');
21
+ const os = require('node:os');
22
+ const Database = require('better-sqlite3');
23
+
24
+ const ROOT_DIR_NAME = '.aioson';
25
+ const OPERATORS_SUBDIR = 'operators';
26
+ const INDEX_DB_FILE = '_index.sqlite';
27
+ const SCHEMA_VERSION = 1;
28
+
29
+ function getRootDir() {
30
+ return path.join(os.homedir(), ROOT_DIR_NAME, OPERATORS_SUBDIR);
31
+ }
32
+
33
+ function getStorageRoot(identity) {
34
+ if (typeof identity !== 'string' || identity === '') {
35
+ throw new Error('getStorageRoot: identity must be a non-empty string');
36
+ }
37
+ return path.join(getRootDir(), identity);
38
+ }
39
+
40
+ function getIndexDbPath() {
41
+ return path.join(getRootDir(), INDEX_DB_FILE);
42
+ }
43
+
44
+ function ensureStorageTree(identity) {
45
+ const root = getStorageRoot(identity);
46
+ fs.mkdirSync(path.join(root, 'decisions'), { recursive: true });
47
+ fs.mkdirSync(path.join(root, 'proposals'), { recursive: true });
48
+ fs.mkdirSync(path.join(root, 'history'), { recursive: true });
49
+ return root;
50
+ }
51
+
52
+ function migrateIndexSchema(db) {
53
+ db.pragma('journal_mode = WAL');
54
+ db.pragma('synchronous = NORMAL');
55
+
56
+ db.exec(`
57
+ CREATE TABLE IF NOT EXISTS schema_version (
58
+ version INTEGER PRIMARY KEY
59
+ );
60
+
61
+ CREATE TABLE IF NOT EXISTS operators (
62
+ identity TEXT PRIMARY KEY,
63
+ created_at TEXT NOT NULL,
64
+ source TEXT NOT NULL,
65
+ last_active_at TEXT NOT NULL
66
+ );
67
+
68
+ CREATE VIRTUAL TABLE IF NOT EXISTS decisions_fts USING fts5(
69
+ identity UNINDEXED,
70
+ slug UNINDEXED,
71
+ signal_type,
72
+ category,
73
+ body,
74
+ last_reinforced UNINDEXED,
75
+ tokenize = 'porter'
76
+ );
77
+
78
+ CREATE INDEX IF NOT EXISTS idx_operators_last_active
79
+ ON operators(last_active_at);
80
+ `);
81
+
82
+ const existing = db.prepare('SELECT version FROM schema_version LIMIT 1').get();
83
+ if (!existing) {
84
+ db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(SCHEMA_VERSION);
85
+ }
86
+ }
87
+
88
+ function openIndexDb() {
89
+ const rootDir = getRootDir();
90
+ fs.mkdirSync(rootDir, { recursive: true });
91
+ const dbPath = getIndexDbPath();
92
+ const db = new Database(dbPath);
93
+ migrateIndexSchema(db);
94
+ return db;
95
+ }
96
+
97
+ function recordIdentityActivity(db, { identity, source }) {
98
+ const now = new Date().toISOString();
99
+ const existing = db.prepare('SELECT identity FROM operators WHERE identity = ?').get(identity);
100
+ if (existing) {
101
+ db.prepare('UPDATE operators SET last_active_at = ? WHERE identity = ?').run(now, identity);
102
+ } else {
103
+ db.prepare(
104
+ 'INSERT INTO operators (identity, created_at, source, last_active_at) VALUES (?, ?, ?, ?)'
105
+ ).run(identity, now, source, now);
106
+ }
107
+ }
108
+
109
+ module.exports = {
110
+ getRootDir,
111
+ getStorageRoot,
112
+ getIndexDbPath,
113
+ ensureStorageTree,
114
+ openIndexDb,
115
+ migrateIndexSchema,
116
+ recordIdentityActivity,
117
+ SCHEMA_VERSION,
118
+ INDEX_DB_FILE,
119
+ ROOT_DIR_NAME,
120
+ OPERATORS_SUBDIR
121
+ };
@@ -322,8 +322,14 @@ async function readDevState(targetDir) {
322
322
  const filePath = path.join(contextDir(targetDir), 'dev-state.md');
323
323
  const content = await readFileSafe(filePath);
324
324
  if (!content) return { exists: false };
325
+ // F1 (workflow-handoff-integrity v1.9.7) — corrupt detection (AC-F1-08).
326
+ // dev-state.md by convention ALWAYS has frontmatter; missing markers OR
327
+ // unparseable frontmatter both indicate corruption.
328
+ const hasFrontmatterMarkers = /^---\r?\n[\s\S]*?\r?\n---/.test(content);
325
329
  const fm = parseFrontmatter(content);
326
- return { exists: true, ...fm, content };
330
+ const fmIsEmpty = Object.keys(fm).length === 0;
331
+ const parseError = content.trim().length > 0 && (!hasFrontmatterMarkers || fmIsEmpty);
332
+ return { exists: true, parseError, ...fm, content };
327
333
  }
328
334
 
329
335
  // ─── Project pulse reader ────────────────────────────────────────────────────
@@ -486,8 +492,18 @@ function buildContextPackage(agent, slug, classification, artifacts, devState, m
486
492
 
487
493
  // ─── Stale dev-state detection ───────────────────────────────────────────────
488
494
 
495
+ /**
496
+ * Stale dev-state detection — synchronous baseline.
497
+ *
498
+ * F1 (workflow-handoff-integrity v1.9.7) extends the previous 2-condition logic
499
+ * with parseError detection (AC-F1-08). For richer detection (orphan in
500
+ * features.md, TTL > 30d), use the async `detectStaleDevStateRich` instead.
501
+ */
489
502
  function detectStaleDevState(devState, slug) {
490
503
  if (!devState.exists) return null;
504
+ if (devState.parseError) {
505
+ return `dev-state.md is corrupt (missing or unparseable frontmatter) — cannot trust as active context. Run \`aioson state:reset\` to clear, then \`aioson state:save --feature=<slug>\` for the current feature`;
506
+ }
491
507
  if (devState.status === 'done') {
492
508
  return `dev-state.md is marked done (feature: ${devState.active_feature || 'unknown'}) — it belongs to a completed session and should not be used as active context`;
493
509
  }
@@ -497,6 +513,78 @@ function detectStaleDevState(devState, slug) {
497
513
  return null;
498
514
  }
499
515
 
516
+ /**
517
+ * Stale dev-state detection — async + features-aware.
518
+ *
519
+ * F1 (workflow-handoff-integrity v1.9.7) — extends `detectStaleDevState` with:
520
+ * - (a) feature marked `done` or `abandoned` in features.md
521
+ * - (b) feature absent from features.md (orphan / cross-project leak)
522
+ * - (c) `last_updated` > 30 days vs now
523
+ *
524
+ * All warnings embed an actionable command suggestion (state:reset or state:save)
525
+ * per AC-F1-01.
526
+ *
527
+ * @param {object} devState Result of `readDevState`.
528
+ * @param {string|null} slug Active feature slug (for mismatch check).
529
+ * @param {string} targetDir Project root (used to read features.md).
530
+ * @param {number} [now] Override Date.now() for testing.
531
+ * @returns {Promise<string|null>} Warning string or null when not stale.
532
+ */
533
+ async function detectStaleDevStateRich(devState, slug, targetDir, now = Date.now()) {
534
+ // Sync baseline first — corrupt / done / mismatch.
535
+ const baseline = detectStaleDevState(devState, slug);
536
+ if (baseline) return baseline;
537
+ if (!devState.exists || !devState.active_feature) return null;
538
+
539
+ // (a)+(b) — cross-reference features.md.
540
+ const featuresPath = path.join(contextDir(targetDir), 'features.md');
541
+ const featuresContent = await readFileSafe(featuresPath);
542
+ if (featuresContent) {
543
+ const featuresMap = parseFeaturesMap(featuresContent);
544
+ const featureStatus = featuresMap.get(devState.active_feature);
545
+ if (featureStatus === 'done' || featureStatus === 'abandoned') {
546
+ return `dev-state.md points to feature "${devState.active_feature}" already marked \`${featureStatus}\` in features.md — run \`aioson state:reset\` to clear, then \`aioson state:save --feature=<new>\` for the next feature`;
547
+ }
548
+ if (featureStatus === undefined && featuresMap.size > 0) {
549
+ return `dev-state.md points to feature "${devState.active_feature}" not present in features.md (orphan or cross-project leak) — run \`aioson state:reset\` to clear`;
550
+ }
551
+ }
552
+
553
+ // (c) — TTL check.
554
+ if (devState.last_updated) {
555
+ const lastUpdatedTs = Date.parse(devState.last_updated);
556
+ if (!Number.isNaN(lastUpdatedTs)) {
557
+ const ageMs = now - lastUpdatedTs;
558
+ const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000;
559
+ if (ageMs > THIRTY_DAYS) {
560
+ const days = Math.round(ageMs / (24 * 60 * 60 * 1000));
561
+ return `dev-state.md is ${days} days old (last_updated: ${devState.last_updated}) — likely stale. Run \`aioson state:reset\` or \`aioson state:save --feature=<current>\` to refresh`;
562
+ }
563
+ }
564
+ }
565
+
566
+ return null;
567
+ }
568
+
569
+ /**
570
+ * Parse features.md table into Map<slug, status>.
571
+ * Tolerant of malformed rows and trailing whitespace.
572
+ */
573
+ function parseFeaturesMap(content) {
574
+ const map = new Map();
575
+ for (const line of String(content || '').split(/\r?\n/)) {
576
+ const trimmed = line.trim();
577
+ if (!trimmed.startsWith('|')) continue;
578
+ const parts = trimmed.split('|').map((p) => p.trim());
579
+ if (parts.length < 5) continue;
580
+ const slug = parts[1];
581
+ const status = parts[2];
582
+ if (!slug || slug === 'slug' || /^-+$/.test(slug)) continue;
583
+ map.set(slug, status);
584
+ }
585
+ return map;
586
+ }
587
+
500
588
  // ─── Readiness evaluator ─────────────────────────────────────────────────────
501
589
 
502
590
  function evaluateReadiness(artifacts, phaseGates, classification, agent, devState, slug) {
@@ -638,6 +726,8 @@ module.exports = {
638
726
  parseGatesFromSpec,
639
727
  readPhaseGates,
640
728
  readDevState,
729
+ detectStaleDevStateRich,
730
+ parseFeaturesMap,
641
731
  readProjectPulse,
642
732
  detectClassification,
643
733
  parseAgentList,
@@ -284,7 +284,7 @@ Interface copy, onboarding text, email content, and marketing text are not withi
284
284
 
285
285
  ## Hard constraints
286
286
  - Use `interaction_language` (fallback: `conversation_language`) from project context for all interaction/output.
287
- - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). Always use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option.
287
+ - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). When a real decision requires user input, use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option. Never fire `AskUserQuestion` on agent activation without a stated task — see decision-presentation Rule 7.
288
288
  - If discovery/architecture is ambiguous, ask for clarification before implementing guessed behavior.
289
289
  - If a UI implementation depends on visual direction and `design_skill` is still blank, do not invent one silently.
290
290
  - No unnecessary rewrites outside current responsibility.
@@ -96,8 +96,8 @@ Run this after the immediate scope gate and before touching code:
96
96
  Behave like a senior engineer sitting next to the user:
97
97
  - start by summarizing the latest confirmed context
98
98
  - say what is confirmed vs inferred when memory is incomplete
99
- - ask what the user wants to do now when the immediate next slice is unclear
100
- - propose the smallest sensible next step
99
+ - if no specific task is provided and no active feature requires continuation, stop after the context summary and wait for the user to direct — do NOT emit `AskUserQuestion` with fabricated options or invent next steps (see decision-presentation Rule 7)
100
+ - when the user has stated a task, propose the smallest sensible next step
101
101
  - implement, inspect, or fix one small validated batch at a time
102
102
  - stop and hand off when the task broadens beyond pair-session boundaries
103
103
 
@@ -185,7 +185,7 @@ Dispatch via harness sub-agent with the tool whitelist `[Read, Grep]`. Read the
185
185
  ## Hard constraints
186
186
 
187
187
  - Use `interaction_language` (fallback: `conversation_language`) from project context for all interaction and output.
188
- - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). Always use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option.
188
+ - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). When a real decision requires user input, use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option. Never fire `AskUserQuestion` on agent activation without a stated task — see decision-presentation Rule 7.
189
189
  - Always check `.aioson/rules/` and relevant `.aioson/docs/` when they exist.
190
190
  - Always apply relevant `.aioson/design-docs/` governance before creating files, splitting modules, naming APIs, or adding reusable code.
191
191
  - Do not silently replace `@product`, `@analyst`, or `@architect` when the task clearly needs them.
@@ -335,7 +335,7 @@ clarification: none | [specific question if confidence is low]
335
335
  - Do not write to any file or directory
336
336
  - Do not activate another agent — only tell the user which to activate
337
337
  - Do not continue into another agent's work after routing
338
- - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). Always use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option.
338
+ - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). When a real decision requires user input, use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option. Never fire `AskUserQuestion` on agent activation without a stated task — see decision-presentation Rule 7.
339
339
  - Use `interaction_language` from context for all interaction. If it is absent, fall back to `conversation_language`.
340
340
  - If `aioson` CLI is available, suggest `aioson workflow:next .` as an alternative tracked path
341
341
 
@@ -341,7 +341,7 @@ If a question is outside product scope, acknowledge it briefly and redirect: "Th
341
341
 
342
342
  ## Hard constraints
343
343
  - Use `interaction_language` (fallback: `conversation_language`) from project context for all interaction and output.
344
- - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). Always use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option.
344
+ - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). When a real decision requires user input, use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option. Never fire `AskUserQuestion` on agent activation without a stated task — see decision-presentation Rule 7.
345
345
  - Never produce a PRD section you haven't actually discussed — write "TBD" instead.
346
346
  - Keep PRD files focused: if a section is growing beyond 5 bullet points, summarize.
347
347
  - Always run the integrity check before starting a feature conversation — never skip it.
@@ -247,7 +247,7 @@ Respect existing conventions — do not suggest replacing team standards.
247
247
 
248
248
  ## Hard constraints
249
249
  - Never silently default `project_type`, `profile`, `classification`, `interaction_language`, or `conversation_language`.
250
- - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). Always use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option.
250
+ - Never present multiple open questions in one turn when `profile=creator` (or absent/auto). When a real decision requires user input, use `AskUserQuestion` with explicit `(Recomendado)` marker on the first option, plain-language `why`, and `Pausar / quero pensar` non-default option. Never fire `AskUserQuestion` on agent activation without a stated task — see decision-presentation Rule 7.
251
251
  - If answers are partial, ask follow-up questions until required fields are complete.
252
252
  - If any assumption is made, ask explicit confirmation before writing the file.
253
253
 
@@ -9,7 +9,7 @@ Load this module when `@deyvin` is about to inspect, explain, fix, or implement
9
9
  ## Pair working style
10
10
 
11
11
  - summarize the latest confirmed context first
12
- - ask what the user wants to do now if the next step is unclear
12
+ - if the user has not stated a task, summarize the context and wait — never fabricate `AskUserQuestion` options just because the agent loaded (see decision-presentation Rule 7)
13
13
  - propose the smallest sensible next step
14
14
  - implement, inspect, or fix one small batch at a time
15
15
  - validate before moving on