@aetheros/specs 0.0.0-stage → 0.1.2

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hariprasath Thanarajah
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@aetheros/specs",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.2",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Abigshana/AetherOS.git",
8
+ "directory": "packages/specs"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": "./src/index.js"
13
+ },
14
+ "scripts": {
15
+ "check": "node --check src/index.js && node --check src/approval.js",
16
+ "test": "node --test test/*.test.mjs"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "dist",
21
+ "bin",
22
+ "templates",
23
+ "kits",
24
+ "README.md"
25
+ ]
26
+ }
@@ -0,0 +1,131 @@
1
+ import {createHash, randomBytes} from 'node:crypto';
2
+ import {existsSync, lstatSync, realpathSync, statSync, openSync, closeSync, fsyncSync, rmSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync} from 'node:fs';
3
+ import {basename, dirname, join, relative, resolve, sep} from 'node:path';
4
+ import {evaluateSpecGate, getSpecArtifacts, validateSpecPath} from './index.js';
5
+
6
+ /** AetherOS keeps its approval records in .aether/specs/; the spec files themselves are only read here, never changed. */
7
+ export const RECORDS_DIR = '.aether/specs';
8
+ export const APPROVED_FILES = Object.freeze(['spec.md', 'plan.md', 'tasks.md']);
9
+
10
+ const text = v => typeof v === 'string' && v.trim().length > 0;
11
+ const posix = p => p.split(sep).join('/');
12
+ const hashFile = file => createHash('sha256').update(readFileSync(file, 'utf8').replace(/\r\n/g, '\n')).digest('hex');
13
+
14
+ // Resolve the nearest existing ancestor so missing record directories cannot hide an escaping symlink.
15
+ function contained(base, target) {
16
+ const path = resolve(base, target);
17
+ const lexical = relative(base, path);
18
+ if (lexical === '..' || lexical.startsWith('..' + sep) || lexical.startsWith(sep)) throw new Error('Path must remain within project root');
19
+ let ancestor = path;
20
+ while (!lstatSync(ancestor, {throwIfNoEntry: false})) ancestor = dirname(ancestor);
21
+ const actual = relative(realpathSync(base), realpathSync(ancestor));
22
+ if (actual === '..' || actual.startsWith('..' + sep) || actual.startsWith(sep)) throw new Error('Path must remain within project root');
23
+ return path;
24
+ }
25
+
26
+ function locate(root, specPath) {
27
+ const base = resolve(root);
28
+ const dir = validateSpecPath(base, specPath);
29
+ const spec = posix(relative(base, dir));
30
+ return {base, dir, spec, record: contained(base, join(RECORDS_DIR, basename(dir) + '.json'))};
31
+ }
32
+
33
+ function readRecord(file, spec) {
34
+ if (!existsSync(file)) return {schemaVersion: 1, spec};
35
+ const value = JSON.parse(readFileSync(file, 'utf8'));
36
+ if (!value || value.schemaVersion !== 1 || value.spec !== spec) throw new Error('Invalid spec record: ' + posix(file));
37
+ return value;
38
+ }
39
+
40
+ function writeRecord(file, value) {
41
+ mkdirSync(dirname(file), {recursive: true});
42
+ const temp = file + '.' + randomBytes(12).toString('hex') + '.tmp';
43
+ let created = false;
44
+ try {
45
+ const handle = openSync(temp, 'wx', 0o600); created = true;
46
+ try { writeFileSync(handle, JSON.stringify(value, null, 2) + '\n'); fsyncSync(handle); } finally { closeSync(handle); }
47
+ renameSync(temp, file);
48
+ } finally { if (created) rmSync(temp, {force: true}); }
49
+ }
50
+
51
+ /** Link a spec folder (specs/<feature>) to a project feature folder and/or a GitHub issue. Keeps any approval. */
52
+ export function linkSpec(root, specPath, {feature, issue} = {}) {
53
+ const {base, spec, record} = locate(root, specPath);
54
+ const value = readRecord(record, spec);
55
+ if (feature !== undefined) {
56
+ if (!text(feature) || !existsSync(resolve(base, feature))) throw new Error('Feature folder does not exist: ' + feature);
57
+ const folder = contained(base, feature);
58
+ if (!statSync(folder).isDirectory()) throw new Error('Feature folder must be a directory');
59
+ value.feature = posix(relative(base, folder));
60
+ }
61
+ if (issue !== undefined) {
62
+ if (!Number.isSafeInteger(issue) || issue < 1) throw new Error('Issue must be a positive number');
63
+ value.issue = issue;
64
+ }
65
+ writeRecord(record, value);
66
+ return value;
67
+ }
68
+
69
+ /**
70
+ * Record a human approval bound to the current content of spec.md, plan.md and tasks.md. Any later change to those
71
+ * files makes the approval stale. The approver name is recorded as given: hosts must verify who is approving.
72
+ */
73
+ export function approveSpec(root, specPath, {approver, now = () => new Date().toISOString()} = {}) {
74
+ if (!text(approver)) throw new Error('An approver is required');
75
+ const {dir, spec, record} = locate(root, specPath);
76
+ const gate = evaluateSpecGate(getSpecArtifacts(root, specPath));
77
+ if (!gate.ready) throw new Error('Spec is not ready for approval; missing ' + gate.missing.join(', '));
78
+ const value = readRecord(record, spec);
79
+ value.approval = {by: approver.trim(), at: now(), hashes: Object.fromEntries(APPROVED_FILES.map(f => [f, hashFile(join(dir, f))]))};
80
+ writeRecord(record, value);
81
+ return value;
82
+ }
83
+
84
+ /** Artifacts, readiness, approval and staleness for one spec. */
85
+ export function specStatus(root, specPath) {
86
+ const {dir, spec, record} = locate(root, specPath);
87
+ const artifacts = getSpecArtifacts(root, specPath);
88
+ const gate = evaluateSpecGate(artifacts);
89
+ const value = readRecord(record, spec);
90
+ const approval = value.approval;
91
+ const changed = approval ? APPROVED_FILES.filter(f => !existsSync(join(dir, f)) || hashFile(join(dir, f)) !== approval.hashes?.[f]) : [];
92
+ return {
93
+ spec, artifacts, ready: gate.ready, missing: gate.missing,
94
+ approved: Boolean(approval) && changed.length === 0, stale: Boolean(approval) && changed.length > 0, changed,
95
+ ...(approval && {approvedBy: approval.by, approvedAt: approval.at}),
96
+ ...(value.feature && {feature: value.feature}), ...(value.issue && {issue: value.issue})
97
+ };
98
+ }
99
+
100
+ /** Status of every directory under specs/. */
101
+ export function listSpecs(root) {
102
+ const specs = join(resolve(root), 'specs');
103
+ if (!existsSync(specs)) return [];
104
+ return readdirSync(specs, {withFileTypes: true}).filter(e => e.isDirectory()).map(e => e.name).sort().map(name => specStatus(root, 'specs/' + name));
105
+ }
106
+
107
+ /** Code may start only when the spec is complete and approved for its current content. */
108
+ export function evaluateApprovedSpecGate(root, specPath) {
109
+ const status = specStatus(root, specPath);
110
+ const reasons = [];
111
+ if (!status.ready) reasons.push('missing ' + status.missing.join(', '));
112
+ if (status.stale) reasons.push('changed since approval: ' + status.changed.join(', '));
113
+ else if (!status.approved) reasons.push('not approved');
114
+ return {ok: reasons.length === 0, reasons, status};
115
+ }
116
+
117
+ /** Records pointing at specs or feature folders that no longer exist. */
118
+ export function checkSpecRecords(root) {
119
+ const base = resolve(root);
120
+ const dir = join(base, RECORDS_DIR);
121
+ const problems = [];
122
+ if (!existsSync(dir)) return {ok: true, problems};
123
+ try { contained(base, dir); } catch { return {ok: false, problems: ['Spec records must remain within project root']}; }
124
+ for (const file of readdirSync(dir).filter(f => f.endsWith('.json'))) {
125
+ let value;
126
+ try { value = JSON.parse(readFileSync(contained(base, join(dir, file)), 'utf8')); } catch { problems.push(`${RECORDS_DIR}/${file}: not valid JSON`); continue; }
127
+ if (!text(value?.spec) || !existsSync(join(base, value.spec))) problems.push(`${RECORDS_DIR}/${file}: spec ${value?.spec} does not exist`);
128
+ if (value?.feature && !existsSync(join(base, value.feature))) problems.push(`${RECORDS_DIR}/${file}: feature folder ${value.feature} does not exist`);
129
+ }
130
+ return {ok: problems.length === 0, problems};
131
+ }
package/src/index.js ADDED
@@ -0,0 +1,87 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import { resolve, relative, sep } from 'node:path';
3
+ const markers = ['specs', '.aether/principles.md'];
4
+ /** What a project has: a specs/ folder (one folder per feature) and, optionally, its written principles in .aether/principles.md. */
5
+ export function detectSpecs(root) {
6
+ const base = resolve(root);
7
+ const found = markers.filter(path => existsSync(resolve(base,path)));
8
+ return {present: found.includes('specs'), found, principles:found.includes('.aether/principles.md')};
9
+ }
10
+ export function validateSpecPath(root, specPath) {
11
+ const base=resolve(root);
12
+ const path=resolve(base,specPath);
13
+ const rel=relative(base,path);
14
+ if (rel==='..' || rel.startsWith('..'+sep) || rel.startsWith(sep)) throw new Error('Spec path must remain within project root');
15
+ if (!rel.startsWith('specs'+sep) && rel!=='specs') throw new Error('Specifications must be located in specs/');
16
+ if (!existsSync(path) || !statSync(path).isDirectory()) throw new Error('Specification directory does not exist');
17
+ const realRoot=realpathSync(base), realPath=realpathSync(path);
18
+ const realRel=relative(realRoot,realPath);
19
+ if(realRel==='..'||realRel.startsWith('..'+sep)||realRel.startsWith(sep)) throw new Error('Spec path must remain within project root');
20
+ return path;
21
+ }
22
+ export function getSpecArtifacts(root, specPath) {
23
+ const path=validateSpecPath(root,specPath);
24
+ return Object.fromEntries(['spec.md','plan.md','tasks.md','research.md','data-model.md'].map(name=>[name,artifactExists(root,path,name)]));
25
+ }
26
+ export function evaluateSpecGate(artifacts,{requireTasks=true}={}) {
27
+ const missing=['spec.md','plan.md',...(requireTasks?['tasks.md']:[])].filter(name=>!artifacts[name]);
28
+ return {ready:missing.length===0,missing};
29
+ }
30
+
31
+
32
+ function artifactExists(root,path,name) {
33
+ const file=resolve(path,name);
34
+ if(!existsSync(file)) return false;
35
+ const rel=relative(realpathSync(root),realpathSync(file));
36
+ if(rel==='..'||rel.startsWith('..'+sep)||rel.startsWith(sep)) throw new Error('Spec artifact must remain within project root');
37
+ return statSync(file).isFile();
38
+ }
39
+ export {APPROVED_FILES, RECORDS_DIR, approveSpec, checkSpecRecords, evaluateApprovedSpecGate, linkSpec, listSpecs, specStatus} from './approval.js';
40
+
41
+ const TEMPLATES = {
42
+ 'spec.md': name => `# ${name}
43
+
44
+ ## Goal
45
+ One or two sentences: what this is for and who it helps.
46
+
47
+ ## Requirements
48
+ - What must be true when it is done, one line each.
49
+
50
+ ## Out of scope
51
+ - What this deliberately does not do.
52
+
53
+ ## Open questions
54
+ - Anything that must be answered before building.
55
+ `,
56
+ 'plan.md': name => `# Plan: ${name}
57
+
58
+ ## Approach
59
+ How it will be built, in plain words.
60
+
61
+ ## Steps
62
+ 1. The first small step.
63
+
64
+ ## Risks
65
+ - What could go wrong and how it will be noticed.
66
+ `,
67
+ 'tasks.md': name => `# Tasks: ${name}
68
+
69
+ - [ ] First task
70
+ `
71
+ };
72
+ /** Create specs/NNN-<name>/ with spec.md, plan.md and tasks.md to fill in. Never replaces an existing folder. Returns the folder (project-relative). */
73
+ export function createSpec(root, name) {
74
+ const base = resolve(root);
75
+ const slug = String(name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 50);
76
+ if (!slug) throw new Error('Give the feature a short name, like: aether spec new search-filters');
77
+ const specs = resolve(base, 'specs');
78
+ mkdirSync(specs, {recursive: true});
79
+ const realSpecs = realpathSync(specs), rel = relative(realpathSync(base), realSpecs);
80
+ if (rel !== 'specs') throw new Error('specs/ must be a real folder inside the project');
81
+ const taken = readdirSync(specs).map(n => /^(\d{3})-/.exec(n)?.[1]).filter(Boolean).map(Number);
82
+ const number = String((taken.length ? Math.max(...taken) : 0) + 1).padStart(3, '0');
83
+ const dir = resolve(specs, `${number}-${slug}`);
84
+ mkdirSync(dir);
85
+ for (const [file, template] of Object.entries(TEMPLATES)) writeFileSync(resolve(dir, file), template(slug.replace(/-/g, ' ')), {flag: 'wx'});
86
+ return `specs/${number}-${slug}`;
87
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.