@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.
- package/CHANGELOG.md +188 -0
- package/README.md +44 -1
- package/package.json +1 -1
- package/src/cli.js +45 -1
- package/src/commands/op-capture.js +146 -0
- package/src/commands/op-forget.js +54 -0
- package/src/commands/op-identity.js +145 -0
- package/src/commands/op-list.js +105 -0
- package/src/commands/op-migrate.js +158 -0
- package/src/commands/op-promote.js +66 -0
- package/src/commands/op-reinforce.js +73 -0
- package/src/commands/op-show.js +71 -0
- package/src/commands/op-stubs.js +67 -0
- package/src/commands/preflight.js +6 -2
- package/src/commands/runtime.js +151 -0
- package/src/commands/state-save.js +61 -0
- package/src/commands/sync-agents-preflight.js +117 -3
- package/src/commands/workflow-next.js +64 -0
- package/src/handoff-contract.js +25 -0
- package/src/lib/agent-semantic-diff.js +199 -0
- package/src/operator-memory/conflict.js +202 -0
- package/src/operator-memory/decay.js +157 -0
- package/src/operator-memory/decision.js +274 -0
- package/src/operator-memory/identity.js +109 -0
- package/src/operator-memory/index-md.js +170 -0
- package/src/operator-memory/loader.js +106 -0
- package/src/operator-memory/proposal.js +179 -0
- package/src/operator-memory/prune.js +81 -0
- package/src/operator-memory/slug.js +90 -0
- package/src/operator-memory/storage.js +121 -0
- package/src/preflight-engine.js +91 -1
- package/template/.aioson/agents/dev.md +1 -1
- package/template/.aioson/agents/deyvin.md +3 -3
- package/template/.aioson/agents/neo.md +1 -1
- package/template/.aioson/agents/product.md +1 -1
- package/template/.aioson/agents/setup.md +1 -1
- package/template/.aioson/docs/deyvin/pair-execution.md +1 -1
- package/template/.aioson/skills/process/decision-presentation/SKILL.md +9 -0
- package/template/AGENTS.md +23 -0
- package/template/CLAUDE.md +23 -0
- 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
|
+
};
|
package/src/preflight-engine.js
CHANGED
|
@@ -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
|
-
|
|
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).
|
|
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
|
-
-
|
|
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).
|
|
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).
|
|
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).
|
|
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).
|
|
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
|
-
-
|
|
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
|